mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 08:15:44 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72ba7949f6 | ||
|
|
8d37c07f24 | ||
|
|
44b3467306 | ||
|
|
5d66603e1b | ||
|
|
96798a6ad5 | ||
|
|
9273fb0134 | ||
|
|
a874e973e2 | ||
|
|
46a88d17c1 | ||
|
|
c391ce0a4b |
@@ -1,8 +1,5 @@
|
||||
bazel-1.0.0.bazelrc
|
||||
|
||||
# for now: filter out annoying TASTY warnings
|
||||
common --ui_event_filters=-INFO
|
||||
|
||||
common --enable_bzlmod
|
||||
|
||||
# Don't use toolchains_llvm for the swift app build
|
||||
@@ -19,24 +16,17 @@ common --worker_sandboxing
|
||||
common --local_test_jobs=64
|
||||
common --jobs=64
|
||||
|
||||
common --cxxopt="--std=c++23"
|
||||
common --cxxopt="--std=c++20"
|
||||
common --cxxopt="-Wno-deprecated-non-prototype"
|
||||
common --host_cxxopt="--std=c++23"
|
||||
common --host_cxxopt="--std=c++20"
|
||||
|
||||
common --javacopt="-Xlint:-options"
|
||||
|
||||
# suppress warnings due to https://developer.apple.com/forums/thread/733317
|
||||
# Use host_linkopt for macOS-specific flags to avoid passing them to Linux cross-compilation
|
||||
common:macos --host_linkopt=-Wl,-no_warn_duplicate_libraries
|
||||
|
||||
# Fix Xcode version caching issue - avoids need for `bazel clean --expunge` after Xcode updates
|
||||
common:macos --repo_env=DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||
common --linkopt=-Wl
|
||||
common:macos --linkopt=-Wl,-no_warn_duplicate_libraries
|
||||
|
||||
common --java_language_version=17
|
||||
common --java_runtime_version=remotejdk_17
|
||||
common --tool_java_language_version=17
|
||||
common --tool_java_runtime_version=remotejdk_17
|
||||
|
||||
# Workspace status for build stamping (git commit, timestamp)
|
||||
common --workspace_status_command=tools/workspace_status.sh
|
||||
common --stamp
|
||||
|
||||
@@ -6,7 +6,4 @@
|
||||
*.bytes filter=lfs diff=lfs merge=lfs -text
|
||||
*.psd filter=lfs diff=lfs merge=lfs -text
|
||||
*.ttf filter=lfs diff=lfs merge=lfs -text
|
||||
# Exclude pre-existing font files that were committed as blobs (not LFS pointers)
|
||||
src/main/csharp/**/GUI[[:space:]]Pro[[:space:]]Kit*/**/*.ttf !filter !diff !merge
|
||||
src/main/csharp/**/Modern[[:space:]]UI[[:space:]]Pack/**/*.ttf !filter !diff !merge
|
||||
*.herodata filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
name: Auth Service Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 'src/main/go/net/eagle0/authservice/**'
|
||||
- 'src/main/go/net/eagle0/authcli/**'
|
||||
- 'src/main/protobuf/net/eagle0/eagle/api/auth.proto'
|
||||
- 'src/main/protobuf/net/eagle0/eagle/api/admin/**'
|
||||
- 'src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto'
|
||||
- 'ci/BUILD.bazel'
|
||||
- '.github/workflows/auth_build.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push_images:
|
||||
description: 'Push images to container registry'
|
||||
required: true
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-auth:
|
||||
runs-on: [self-hosted, bazel]
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-auth.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Build Auth Server Docker image
|
||||
id: build-auth
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
# Build auth server image (Go binary has explicit goos/goarch in BUILD.bazel)
|
||||
bazel build //ci:auth_server_image
|
||||
|
||||
# Save the resolved path before any other bazel command changes bazel-bin symlink
|
||||
IMAGE_PATH=$(readlink -f bazel-bin/ci/auth_server_image)
|
||||
echo "Image path: $IMAGE_PATH"
|
||||
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to DigitalOcean Container Registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
run: |
|
||||
mkdir -p ~/.docker
|
||||
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
|
||||
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
mkdir -p .docker
|
||||
cp ~/.docker/config.json .docker/
|
||||
|
||||
- name: Push Auth image to DO registry
|
||||
id: push-auth
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DOCKER_CONFIG: ${{ github.workspace }}/.docker
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
AUTH_IMAGE="${{ steps.build-auth.outputs.image_path }}"
|
||||
echo "Using Auth image: $AUTH_IMAGE"
|
||||
|
||||
if [ -z "$AUTH_IMAGE" ] || [ ! -d "$AUTH_IMAGE" ]; then
|
||||
echo "ERROR: Auth image not found at: $AUTH_IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build the push target to get crane in runfiles
|
||||
bazel build //ci:auth_server_push
|
||||
|
||||
# Use crane directly for push
|
||||
CRANE="bazel-bin/ci/push_auth_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
|
||||
echo "Using crane: $CRANE"
|
||||
|
||||
# Push with SHA tag
|
||||
GIT_SHA=$(git rev-parse --short=8 HEAD)
|
||||
IMAGE_TAG="registry.digitalocean.com/eagle0/auth-server:${GIT_SHA}"
|
||||
echo "Pushing auth image: $IMAGE_TAG"
|
||||
$CRANE push "$AUTH_IMAGE" "$IMAGE_TAG"
|
||||
|
||||
# Output the full image tag for deploy step
|
||||
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Also update :latest for convenience
|
||||
echo "Copying to :latest tag"
|
||||
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/auth-server:latest"
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-auth]
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
environment: production
|
||||
env:
|
||||
AUTH_IMAGE: ${{ needs.build-auth.outputs.image_tag }}
|
||||
DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }}
|
||||
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
|
||||
FASTMAIL_API_TOKEN: ${{ secrets.FASTMAIL_API_TOKEN }}
|
||||
FASTMAIL_FROM_EMAIL: ${{ secrets.FASTMAIL_FROM_EMAIL }}
|
||||
FASTMAIL_FROM_NAME: ${{ secrets.FASTMAIL_FROM_NAME }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Copy update-env script to server
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.DO_DROPLET_IP }}
|
||||
username: deploy
|
||||
key: ${{ secrets.DO_SSH_KEY }}
|
||||
source: "deploy/update-env.sh,deploy/env.template"
|
||||
target: /opt/eagle0/
|
||||
strip_components: 1
|
||||
|
||||
- name: Deploy auth service to production
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.DO_DROPLET_IP }}
|
||||
username: deploy
|
||||
key: ${{ secrets.DO_SSH_KEY }}
|
||||
script_stop: true
|
||||
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,JWT_PRIVATE_KEY,FASTMAIL_API_TOKEN,FASTMAIL_FROM_EMAIL,FASTMAIL_FROM_NAME
|
||||
script: |
|
||||
set -x
|
||||
cd /opt/eagle0
|
||||
|
||||
# Update env vars using shared script (preserves vars set by other workflows)
|
||||
chmod +x update-env.sh
|
||||
./update-env.sh \
|
||||
"AUTH_IMAGE=${AUTH_IMAGE}" \
|
||||
"DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}" \
|
||||
"DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}" \
|
||||
"GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}" \
|
||||
"GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}" \
|
||||
"JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}" \
|
||||
"FASTMAIL_API_TOKEN=${FASTMAIL_API_TOKEN}" \
|
||||
"FASTMAIL_FROM_EMAIL=${FASTMAIL_FROM_EMAIL}" \
|
||||
"FASTMAIL_FROM_NAME=${FASTMAIL_FROM_NAME}"
|
||||
|
||||
# Login to registry
|
||||
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
|
||||
|
||||
echo "Deploying auth service: $AUTH_IMAGE"
|
||||
|
||||
# Use crane to pull image
|
||||
echo "Installing crane..."
|
||||
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
|
||||
chmod +x crane
|
||||
|
||||
echo "Pulling Auth image with crane..."
|
||||
./crane pull "${AUTH_IMAGE}" auth.tar || { echo "ERROR: Failed to pull auth image"; exit 1; }
|
||||
echo "Loading Auth image into Docker..."
|
||||
docker load -i auth.tar
|
||||
rm auth.tar
|
||||
rm ./crane
|
||||
|
||||
# Only recreate the auth container (not eagle, shardok, etc.)
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate auth
|
||||
|
||||
# Wait for health check
|
||||
sleep 5
|
||||
docker compose -f docker-compose.prod.yml ps auth
|
||||
|
||||
# Verify container is using correct image
|
||||
echo "=== Verifying auth container image ==="
|
||||
docker compose -f docker-compose.prod.yml images auth
|
||||
|
||||
# Cleanup old images
|
||||
docker image prune -f
|
||||
@@ -26,7 +26,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: [self-hosted, bazel]
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -34,54 +34,10 @@ jobs:
|
||||
with:
|
||||
lfs: false
|
||||
- name: Run tests
|
||||
id: test
|
||||
continue-on-error: true
|
||||
run: bazel test --build_event_json_file=test.json //src/test/... //src/main/go/...
|
||||
- name: Collect failed test logs
|
||||
if: always()
|
||||
run: |
|
||||
# Remove any existing failed_test_logs directory and create fresh
|
||||
rm -rf failed_test_logs
|
||||
mkdir -p failed_test_logs
|
||||
# Extract failed test targets from test.json and copy their logs
|
||||
# The test.json is in JSONL format - one JSON object per line
|
||||
# We look for lines with testResult that have a status other than PASSED
|
||||
if [ -f test.json ]; then
|
||||
grep '"testResult"' test.json | \
|
||||
grep '"status"' | \
|
||||
grep -v '"status":"PASSED"' | \
|
||||
grep -o '"label":"[^"]*"' | \
|
||||
cut -d'"' -f4 | \
|
||||
sort -u | \
|
||||
while read target; do
|
||||
# Convert target like //src/test/cpp/...:test_name to path
|
||||
log_path=$(echo "$target" | sed 's|^//||' | sed 's|:|/|')
|
||||
if [ -f "bazel-testlogs/$log_path/test.log" ]; then
|
||||
log_name=$(echo "$log_path" | tr '/' '_')
|
||||
if cp "bazel-testlogs/$log_path/test.log" "failed_test_logs/${log_name}.log"; then
|
||||
echo "Collected log for failed test: $target"
|
||||
else
|
||||
echo "Error: Failed to copy log for $target"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
# List what we collected
|
||||
echo "Collected logs:"
|
||||
ls -lh failed_test_logs/ 2>/dev/null || echo "No logs collected"
|
||||
- name: Archive test results
|
||||
if: always()
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test.json
|
||||
path: test.json
|
||||
- name: Archive failed test logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: failed-test-logs
|
||||
path: failed_test_logs/
|
||||
if-no-files-found: ignore
|
||||
- name: Fail if tests failed
|
||||
if: steps.test.outcome == 'failure'
|
||||
run: exit 1
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Build Protos
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "src/main/protobuf/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
- name: Run tests
|
||||
run: ./scripts/build_protos.sh
|
||||
@@ -1,137 +0,0 @@
|
||||
name: Build Linux Sysroot
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Sysroot version (e.g., v2, v3)'
|
||||
required: true
|
||||
default: 'v2'
|
||||
type: string
|
||||
architecture:
|
||||
description: 'Target architecture'
|
||||
required: true
|
||||
default: 'amd64'
|
||||
type: choice
|
||||
options:
|
||||
- amd64
|
||||
- arm64
|
||||
- both
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-sysroot-amd64:
|
||||
if: ${{ inputs.architecture == 'amd64' || inputs.architecture == 'both' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build sysroot
|
||||
run: ./tools/sysroot/build_sysroot.sh
|
||||
|
||||
- name: Upload sysroot artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ubuntu-noble-sysroot-amd64
|
||||
path: tools/sysroot/output/
|
||||
|
||||
- name: Install AWS CLI
|
||||
run: |
|
||||
if ! command -v aws &> /dev/null; then
|
||||
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
|
||||
unzip -q awscliv2.zip
|
||||
sudo ./aws/install
|
||||
fi
|
||||
|
||||
- name: Upload to DigitalOcean Spaces
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: |
|
||||
# Upload sysroot tarball to DO Spaces
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.tar.xz \
|
||||
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
--acl public-read
|
||||
|
||||
# Upload sha256 file
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256 \
|
||||
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.sha256 \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
--acl public-read
|
||||
|
||||
echo ""
|
||||
echo "=== AMD64 Sysroot uploaded ==="
|
||||
echo "URL: https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz"
|
||||
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)"
|
||||
echo ""
|
||||
echo "Update MODULE.bazel with:"
|
||||
echo "sysroot("
|
||||
echo " name = \"linux_sysroot\","
|
||||
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)\","
|
||||
echo " urls = [\"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
|
||||
echo ")"
|
||||
|
||||
build-sysroot-arm64:
|
||||
if: ${{ inputs.architecture == 'arm64' || inputs.architecture == 'both' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU for ARM64 emulation
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build ARM64 sysroot
|
||||
run: ./tools/sysroot/build_sysroot_arm64.sh
|
||||
|
||||
- name: Upload sysroot artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ubuntu-noble-sysroot-arm64
|
||||
path: tools/sysroot/output/
|
||||
|
||||
- name: Install AWS CLI
|
||||
run: |
|
||||
if ! command -v aws &> /dev/null; then
|
||||
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
|
||||
unzip -q awscliv2.zip
|
||||
sudo ./aws/install
|
||||
fi
|
||||
|
||||
- name: Upload to DigitalOcean Spaces
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: |
|
||||
# Upload sysroot tarball to DO Spaces
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_arm64_sysroot.tar.xz \
|
||||
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
--acl public-read
|
||||
|
||||
# Upload sha256 file
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256 \
|
||||
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.sha256 \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
--acl public-read
|
||||
|
||||
echo ""
|
||||
echo "=== ARM64 Sysroot uploaded ==="
|
||||
echo "URL: https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz"
|
||||
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256)"
|
||||
echo ""
|
||||
echo "Update MODULE.bazel with:"
|
||||
echo "sysroot("
|
||||
echo " name = \"linux_sysroot_arm64\","
|
||||
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256)\","
|
||||
echo " urls = [\"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz\"],"
|
||||
echo ")"
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Client Presigner
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- ".github/workflows/client_presigner.yml"
|
||||
- "src/main/go/net/eagle0/client_download/**"
|
||||
- "src/main/go/net/eagle0/util/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/client_presigner.yml"
|
||||
- "src/main/go/net/eagle0/client_download/**"
|
||||
- "src/main/go/net/eagle0/util/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
client-presigner:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
clean: false
|
||||
- name: Build Client Presigner
|
||||
run: bazel build --platforms=@io_bazel_rules_go//go/toolchain:linux_amd64 //src/main/go/net/eagle0/client_download
|
||||
- name: Archive presigner binary
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: client_download
|
||||
path: bazel-bin/src/main/go/net/eagle0/client_download/client_download_/client_download
|
||||
@@ -1,312 +0,0 @@
|
||||
name: Docker Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
# Note: C++ changes trigger shardok_arm64_build.yml instead
|
||||
- 'src/main/go/**'
|
||||
- 'src/main/scala/**'
|
||||
- 'src/main/protobuf/**'
|
||||
- 'src/main/resources/**'
|
||||
- 'ci/BUILD.bazel'
|
||||
- 'MODULE.bazel'
|
||||
- 'docker-compose.prod.yml'
|
||||
- 'nginx/**'
|
||||
- '.github/workflows/docker_build.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push_images:
|
||||
description: 'Push images to container registry'
|
||||
required: true
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
# Only allow one deployment at a time to prevent race conditions
|
||||
concurrency:
|
||||
group: docker-build-deploy
|
||||
cancel-in-progress: false # Don't cancel running deployments, queue new ones
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Single consolidated build job - builds all images with one bazel invocation
|
||||
# This uses 1 runner slot instead of 4, and Bazel parallelizes internally
|
||||
# NOTE: Must run on a runner with the bazel label to ensure consistent Bazel cache
|
||||
build-all:
|
||||
runs-on: [self-hosted, bazel]
|
||||
outputs:
|
||||
eagle_image_tag: ${{ steps.push-images.outputs.eagle_image_tag }}
|
||||
admin_image_tag: ${{ steps.push-images.outputs.admin_image_tag }}
|
||||
jfr_sidecar_image_tag: ${{ steps.push-images.outputs.jfr_sidecar_image_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Build all Docker images
|
||||
id: build-all
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
# Build ALL images in a single bazel command - Bazel parallelizes internally
|
||||
# Note: Shardok is built separately for ARM64 and deployed to Hetzner
|
||||
echo "=== Building Docker images ==="
|
||||
bazel build \
|
||||
--platforms=//:linux_x86_64 \
|
||||
--extra_toolchains=@llvm_toolchain_linux//:all \
|
||||
//ci:eagle_server_image \
|
||||
//ci:admin_server_image \
|
||||
//ci:jfr_sidecar_image \
|
||||
//src/main/go/net/eagle0/warmup:warmup_linux_amd64
|
||||
|
||||
# Copy warmup binary to scripts/ for deployment
|
||||
mkdir -p scripts/bin
|
||||
cp bazel-bin/src/main/go/net/eagle0/warmup/warmup_linux_amd64_/warmup_linux_amd64 scripts/bin/warmup
|
||||
|
||||
# Save all image paths before any other bazel command changes bazel-bin symlink
|
||||
EAGLE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
|
||||
ADMIN_PATH=$(readlink -f bazel-bin/ci/admin_server_image)
|
||||
JFR_PATH=$(readlink -f bazel-bin/ci/jfr_sidecar_image)
|
||||
|
||||
echo "eagle_path=$EAGLE_PATH" >> $GITHUB_OUTPUT
|
||||
echo "admin_path=$ADMIN_PATH" >> $GITHUB_OUTPUT
|
||||
echo "jfr_path=$JFR_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "=== Image paths ==="
|
||||
echo "Eagle: $EAGLE_PATH"
|
||||
echo "Admin: $ADMIN_PATH"
|
||||
echo "JFR Sidecar: $JFR_PATH"
|
||||
|
||||
- name: Login to DigitalOcean Container Registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
run: |
|
||||
mkdir -p ~/.docker
|
||||
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
|
||||
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
mkdir -p .docker
|
||||
cp ~/.docker/config.json .docker/
|
||||
|
||||
- name: Push all images to DO registry
|
||||
id: push-images
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DOCKER_CONFIG: ${{ github.workspace }}/.docker
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
GIT_SHA=$(git rev-parse --short=8 HEAD)
|
||||
|
||||
# Get crane from push target runfiles
|
||||
bazel build //ci:eagle_server_push
|
||||
CRANE="bazel-bin/ci/push_eagle_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
|
||||
|
||||
if [ ! -e "$CRANE" ]; then
|
||||
# Fallback: find any Darwin crane
|
||||
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
|
||||
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
|
||||
fi
|
||||
|
||||
if [ -z "$CRANE" ] || [ ! -e "$CRANE" ]; then
|
||||
echo "ERROR: crane not found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Using crane: $CRANE"
|
||||
|
||||
# Push Eagle image
|
||||
EAGLE_IMAGE="${{ steps.build-all.outputs.eagle_path }}"
|
||||
EAGLE_TAG="registry.digitalocean.com/eagle0/eagle-server:${GIT_SHA}"
|
||||
echo "Pushing Eagle: $EAGLE_TAG"
|
||||
$CRANE push "$EAGLE_IMAGE" "$EAGLE_TAG"
|
||||
$CRANE copy "$EAGLE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest"
|
||||
echo "eagle_image_tag=$EAGLE_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Push Admin image
|
||||
ADMIN_IMAGE="${{ steps.build-all.outputs.admin_path }}"
|
||||
ADMIN_TAG="registry.digitalocean.com/eagle0/admin-server:${GIT_SHA}"
|
||||
echo "Pushing Admin: $ADMIN_TAG"
|
||||
$CRANE push "$ADMIN_IMAGE" "$ADMIN_TAG"
|
||||
$CRANE copy "$ADMIN_TAG" "registry.digitalocean.com/eagle0/admin-server:latest"
|
||||
echo "admin_image_tag=$ADMIN_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Push JFR Sidecar image
|
||||
JFR_IMAGE="${{ steps.build-all.outputs.jfr_path }}"
|
||||
JFR_TAG="registry.digitalocean.com/eagle0/jfr-sidecar:${GIT_SHA}"
|
||||
echo "Pushing JFR Sidecar: $JFR_TAG"
|
||||
$CRANE push "$JFR_IMAGE" "$JFR_TAG"
|
||||
$CRANE copy "$JFR_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest"
|
||||
echo "jfr_sidecar_image_tag=$JFR_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "=== All images pushed successfully ==="
|
||||
|
||||
deploy:
|
||||
runs-on: [self-hosted, bazel]
|
||||
needs: [build-all]
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
environment: production
|
||||
env:
|
||||
EAGLE_IMAGE: ${{ needs.build-all.outputs.eagle_image_tag }}
|
||||
ADMIN_IMAGE: ${{ needs.build-all.outputs.admin_image_tag }}
|
||||
JFR_SIDECAR_IMAGE: ${{ needs.build-all.outputs.jfr_sidecar_image_tag }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GPT_MODEL_NAME: ${{ secrets.GPT_MODEL_NAME }}
|
||||
EAGLE_ENABLE_S3: ${{ secrets.EAGLE_ENABLE_S3 }}
|
||||
DO_SPACES_ACCESS_KEY: ${{ secrets.DO_SPACES_ACCESS_KEY }}
|
||||
DO_SPACES_SECRET_KEY: ${{ secrets.DO_SPACES_SECRET_KEY }}
|
||||
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
|
||||
DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }}
|
||||
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||
SHARDOK_ADDRESS: ${{ secrets.SHARDOK_ADDRESS }}
|
||||
SHARDOK_AUTH_TOKEN: ${{ secrets.SHARDOK_AUTH_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
FASTMAIL_API_TOKEN: ${{ secrets.FASTMAIL_API_TOKEN }}
|
||||
FASTMAIL_FROM_EMAIL: ${{ secrets.FASTMAIL_FROM_EMAIL }}
|
||||
FASTMAIL_FROM_NAME: ${{ secrets.FASTMAIL_FROM_NAME }}
|
||||
DO_DROPLET_IP: ${{ secrets.DO_DROPLET_IP }}
|
||||
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DO_SSH_KEY }}" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh-keyscan -H "$DO_DROPLET_IP" >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
- name: Build warmup tool
|
||||
run: |
|
||||
bazel build //src/main/go/net/eagle0/warmup:warmup_linux_amd64
|
||||
mkdir -p scripts/bin
|
||||
cp bazel-bin/src/main/go/net/eagle0/warmup/warmup_linux_amd64_/warmup_linux_amd64 scripts/bin/warmup
|
||||
|
||||
- name: Copy config files to droplet
|
||||
run: |
|
||||
# Create directory structure on remote
|
||||
ssh -i ~/.ssh/deploy_key deploy@"$DO_DROPLET_IP" bash -s << 'SETUP_DIRS'
|
||||
set -e
|
||||
mkdir -p /opt/eagle0/scripts/bin /opt/eagle0/nginx /opt/eagle0/deploy
|
||||
rm -f /opt/eagle0/scripts/bin/warmup
|
||||
SETUP_DIRS
|
||||
|
||||
# Copy files
|
||||
scp -i ~/.ssh/deploy_key docker-compose.prod.yml deploy@"$DO_DROPLET_IP":/opt/eagle0/
|
||||
scp -i ~/.ssh/deploy_key nginx/nginx.conf deploy@"$DO_DROPLET_IP":/opt/eagle0/nginx/
|
||||
scp -i ~/.ssh/deploy_key scripts/deploy-blue-green.sh scripts/warmup-eagle.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/
|
||||
scp -i ~/.ssh/deploy_key scripts/bin/warmup deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/bin/
|
||||
scp -i ~/.ssh/deploy_key deploy/env.template deploy/update-env.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/deploy/
|
||||
|
||||
- name: Deploy to production droplet
|
||||
run: |
|
||||
ssh -i ~/.ssh/deploy_key deploy@"$DO_DROPLET_IP" bash -s << DEPLOY_SCRIPT
|
||||
set -ex
|
||||
cd /opt/eagle0
|
||||
|
||||
# Environment variables passed via heredoc
|
||||
EAGLE_IMAGE="${EAGLE_IMAGE}"
|
||||
ADMIN_IMAGE="${ADMIN_IMAGE}"
|
||||
JFR_SIDECAR_IMAGE="${JFR_SIDECAR_IMAGE}"
|
||||
OPENAI_API_KEY="${OPENAI_API_KEY}"
|
||||
GPT_MODEL_NAME="${GPT_MODEL_NAME}"
|
||||
EAGLE_ENABLE_S3="${EAGLE_ENABLE_S3}"
|
||||
DO_SPACES_ACCESS_KEY="${DO_SPACES_ACCESS_KEY}"
|
||||
DO_SPACES_SECRET_KEY="${DO_SPACES_SECRET_KEY}"
|
||||
JWT_PRIVATE_KEY="${JWT_PRIVATE_KEY}"
|
||||
DISCORD_CLIENT_ID="${DISCORD_CLIENT_ID}"
|
||||
DISCORD_CLIENT_SECRET="${DISCORD_CLIENT_SECRET}"
|
||||
GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID}"
|
||||
GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET}"
|
||||
SHARDOK_ADDRESS="${SHARDOK_ADDRESS}"
|
||||
SHARDOK_AUTH_TOKEN="${SHARDOK_AUTH_TOKEN}"
|
||||
SENTRY_DSN="${SENTRY_DSN}"
|
||||
FASTMAIL_API_TOKEN="${FASTMAIL_API_TOKEN}"
|
||||
FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
|
||||
FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
|
||||
DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
|
||||
|
||||
# Check Docker has IPv6 support
|
||||
if ! cat /etc/docker/daemon.json 2>/dev/null | grep -q '"ip6tables"'; then
|
||||
echo "WARNING: Docker IPv6 not configured. Eagle may not reach Hetzner Shardok."
|
||||
fi
|
||||
|
||||
# Update env vars
|
||||
chmod +x deploy/update-env.sh
|
||||
cd deploy && ./update-env.sh \
|
||||
"EAGLE_IMAGE=\${EAGLE_IMAGE}" \
|
||||
"ADMIN_IMAGE=\${ADMIN_IMAGE}" \
|
||||
"JFR_SIDECAR_IMAGE=\${JFR_SIDECAR_IMAGE}" \
|
||||
"OPENAI_API_KEY=\${OPENAI_API_KEY}" \
|
||||
"GPT_MODEL_NAME=\${GPT_MODEL_NAME:-gpt-4o}" \
|
||||
"EAGLE_ENABLE_S3=\${EAGLE_ENABLE_S3:-false}" \
|
||||
"DO_SPACES_ACCESS_KEY=\${DO_SPACES_ACCESS_KEY}" \
|
||||
"DO_SPACES_SECRET_KEY=\${DO_SPACES_SECRET_KEY}" \
|
||||
"JWT_PRIVATE_KEY=\${JWT_PRIVATE_KEY}" \
|
||||
"DISCORD_CLIENT_ID=\${DISCORD_CLIENT_ID}" \
|
||||
"DISCORD_CLIENT_SECRET=\${DISCORD_CLIENT_SECRET}" \
|
||||
"GOOGLE_CLIENT_ID=\${GOOGLE_CLIENT_ID}" \
|
||||
"GOOGLE_CLIENT_SECRET=\${GOOGLE_CLIENT_SECRET}" \
|
||||
"SHARDOK_ADDRESS=\${SHARDOK_ADDRESS:-shardok:40042}" \
|
||||
"SHARDOK_AUTH_TOKEN=\${SHARDOK_AUTH_TOKEN}" \
|
||||
"SENTRY_DSN=\${SENTRY_DSN}" \
|
||||
"FASTMAIL_API_TOKEN=\${FASTMAIL_API_TOKEN}" \
|
||||
"FASTMAIL_FROM_EMAIL=\${FASTMAIL_FROM_EMAIL}" \
|
||||
"FASTMAIL_FROM_NAME=\${FASTMAIL_FROM_NAME}"
|
||||
cd ..
|
||||
|
||||
# Login to registry
|
||||
echo "\$DO_REGISTRY_TOKEN" | docker login registry.digitalocean.com -u "\$DO_REGISTRY_TOKEN" --password-stdin
|
||||
|
||||
echo "Using images: \$EAGLE_IMAGE, \$ADMIN_IMAGE, \$JFR_SIDECAR_IMAGE"
|
||||
|
||||
# Install crane for pulling OCI images
|
||||
echo "Installing crane..."
|
||||
rm -f crane
|
||||
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
|
||||
chmod +x crane
|
||||
|
||||
# Pull and load all images
|
||||
echo "Pulling Eagle image..."
|
||||
./crane pull "\${EAGLE_IMAGE}" eagle.tar && docker load -i eagle.tar && rm eagle.tar
|
||||
|
||||
echo "Pulling Admin image..."
|
||||
./crane pull "\${ADMIN_IMAGE}" admin.tar && docker load -i admin.tar && rm admin.tar
|
||||
|
||||
echo "Pulling JFR Sidecar image..."
|
||||
./crane pull "\${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar && docker load -i jfr-sidecar.tar && rm jfr-sidecar.tar
|
||||
|
||||
# Pull other compose images
|
||||
docker pull nginx:alpine || true
|
||||
docker pull certbot/certbot || true
|
||||
|
||||
echo "All images pulled successfully"
|
||||
|
||||
# Stop local shardok container if running (now runs on Hetzner)
|
||||
docker stop shardok-server 2>/dev/null || true
|
||||
docker rm shardok-server 2>/dev/null || true
|
||||
|
||||
# Deploy Eagle with blue-green (handles eagle, nginx, admin, jfr-sidecar)
|
||||
# Note: Shardok runs on Hetzner, deployed separately via shardok_arm64_build.yml
|
||||
chmod +x /opt/eagle0/scripts/*.sh
|
||||
[ -f "/opt/eagle0/scripts/bin/warmup" ] && chmod +x /opt/eagle0/scripts/bin/warmup
|
||||
GIT_SHA=\$(echo "\${EAGLE_IMAGE}" | sed 's/.*://')
|
||||
/opt/eagle0/scripts/deploy-blue-green.sh "\${GIT_SHA}"
|
||||
|
||||
# Ensure auth is running
|
||||
docker compose -f docker-compose.prod.yml up -d auth
|
||||
|
||||
# Verify
|
||||
sleep 10
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
docker compose -f docker-compose.prod.yml images
|
||||
|
||||
# Cleanup
|
||||
docker container prune -f
|
||||
docker image prune -f
|
||||
DEPLOY_SCRIPT
|
||||
@@ -10,14 +10,13 @@ on:
|
||||
paths:
|
||||
- ".github/workflows/installer_build.yml"
|
||||
- "src/main/csharp/net/eagle0/clients/win/installer/**"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-installer:
|
||||
runs-on: [self-hosted, bazel]
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -30,19 +29,6 @@ jobs:
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
|
||||
- name: Inject manifest public key
|
||||
env:
|
||||
MANIFEST_PUBLIC_KEY: ${{ secrets.MANIFEST_PUBLIC_KEY }}
|
||||
run: |
|
||||
if [ -n "$MANIFEST_PUBLIC_KEY" ]; then
|
||||
CONFIG_FILE="src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/configuration.txt"
|
||||
echo "manifest_public_key = $MANIFEST_PUBLIC_KEY" >> "$CONFIG_FILE"
|
||||
echo "Injected manifest public key into configuration.txt"
|
||||
cat "$CONFIG_FILE"
|
||||
else
|
||||
echo "MANIFEST_PUBLIC_KEY not set, skipping injection"
|
||||
fi
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/EagleInstaller.csproj
|
||||
|
||||
@@ -82,30 +68,15 @@ jobs:
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
|
||||
run: |
|
||||
# Create installer manifest content
|
||||
# Create installer manifest content
|
||||
INSTALLER_SHA=$(sha256sum ./installer-output/EagleInstaller.exe | cut -d' ' -f1)
|
||||
echo "installer_version=$INSTALLER_SHA" > /tmp/installer_manifest.txt
|
||||
echo "installer_url=installer/EagleInstaller.exe" >> /tmp/installer_manifest.txt
|
||||
|
||||
|
||||
echo "=== Installer manifest content ==="
|
||||
cat /tmp/installer_manifest.txt
|
||||
echo "=================================="
|
||||
|
||||
# Write signing key to temp file (if available)
|
||||
SIGNING_ARGS=""
|
||||
if [ -n "$MANIFEST_SIGNING_KEY" ]; then
|
||||
echo "$MANIFEST_SIGNING_KEY" > /tmp/manifest_signing_key
|
||||
chmod 600 /tmp/manifest_signing_key
|
||||
SIGNING_ARGS="/tmp/manifest_signing_key"
|
||||
echo "Manifest signing key available"
|
||||
else
|
||||
echo "Warning: MANIFEST_SIGNING_KEY not set, manifest will be unsigned"
|
||||
fi
|
||||
|
||||
# Update the unified manifest (with optional signing)
|
||||
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- installer /tmp/installer_manifest.txt $SIGNING_ARGS
|
||||
|
||||
# Cleanup
|
||||
rm -f /tmp/manifest_signing_key
|
||||
|
||||
# Update the unified manifest
|
||||
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- installer /tmp/installer_manifest.txt
|
||||
@@ -1,268 +0,0 @@
|
||||
name: Mac Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- ".github/workflows/mac_build.yml"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/**"
|
||||
- "src/main/protobuf/net/eagle0/common/**"
|
||||
- "src/main/protobuf/net/eagle0/shardok/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/api/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/common/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/views/**"
|
||||
- "src/main/go/net/eagle0/build/mac_build_handler/**"
|
||||
- "scripts/build_protos.sh"
|
||||
- "scripts/build_mac_plugin.sh"
|
||||
- "scripts/inject_sparkle.sh"
|
||||
- "scripts/codesign_mac_app.sh"
|
||||
- "scripts/notarize_submit.sh"
|
||||
- "scripts/notarize_wait.sh"
|
||||
- "ci/github_actions/build_mac.sh"
|
||||
- "ci/github_actions/build_unity_mac.sh"
|
||||
- "ci/mac/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/mac_build.yml"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/**"
|
||||
- "src/main/protobuf/net/eagle0/common/**"
|
||||
- "src/main/protobuf/net/eagle0/shardok/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/api/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/common/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/views/**"
|
||||
- "src/main/go/net/eagle0/build/mac_build_handler/**"
|
||||
- "scripts/build_mac_plugin.sh"
|
||||
- "scripts/inject_sparkle.sh"
|
||||
- "scripts/codesign_mac_app.sh"
|
||||
- "scripts/notarize_submit.sh"
|
||||
- "scripts/notarize_wait.sh"
|
||||
- "ci/github_actions/build_mac.sh"
|
||||
- "ci/github_actions/build_unity_mac.sh"
|
||||
- "ci/mac/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
skip_signing:
|
||||
description: 'Skip code signing, notarization, and deploy (build only)'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-sign:
|
||||
runs-on: [self-hosted, macOS, unity-mac]
|
||||
outputs:
|
||||
submission_id: ${{ steps.notarize-submit.outputs.submission_id }}
|
||||
should_deploy: ${{ steps.check-deploy.outputs.should_deploy }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
clean: false
|
||||
fetch-depth: 0 # For version numbering from git history
|
||||
|
||||
- name: Pull LFS files
|
||||
run: git lfs pull
|
||||
|
||||
- name: Restore Library/
|
||||
run: ./ci/github_actions/restore_library.sh
|
||||
|
||||
- name: Build Mac Unity
|
||||
run: ./ci/github_actions/build_unity_mac.sh "/tmp/eagle0/eagle0MAC"
|
||||
|
||||
- name: Persist Library/
|
||||
run: ./ci/github_actions/persist_library.sh
|
||||
|
||||
- name: Inject Sparkle Framework
|
||||
if: success()
|
||||
env:
|
||||
SPARKLE_EDDSA_PUBLIC_KEY: ${{ secrets.SPARKLE_EDDSA_PUBLIC_KEY }}
|
||||
run: |
|
||||
chmod +x ./scripts/inject_sparkle.sh
|
||||
./scripts/inject_sparkle.sh "/tmp/eagle0/eagle0MAC/eagle0.app"
|
||||
|
||||
- name: Check if should deploy
|
||||
id: check-deploy
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ github.event.inputs.skip_signing }}" == "true" ]]; then
|
||||
echo "should_deploy=false" >> $GITHUB_OUTPUT
|
||||
elif [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
|
||||
echo "should_deploy=true" >> $GITHUB_OUTPUT
|
||||
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
echo "should_deploy=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "should_deploy=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Import Code Signing Certificate
|
||||
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
|
||||
env:
|
||||
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
|
||||
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
|
||||
run: |
|
||||
# Generate random keychain password (only used within this workflow run)
|
||||
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
|
||||
echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> $GITHUB_ENV
|
||||
|
||||
# Decode certificate
|
||||
echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12
|
||||
|
||||
# Delete any existing keychain from previous runs
|
||||
security delete-keychain build.keychain 2>/dev/null || true
|
||||
|
||||
# Create temporary keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security default-keychain -s build.keychain
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
|
||||
# Import certificate
|
||||
security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign
|
||||
|
||||
# Allow codesign to access keychain
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
|
||||
|
||||
# Clean up
|
||||
rm certificate.p12
|
||||
|
||||
- name: Code Sign App
|
||||
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
|
||||
env:
|
||||
SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
|
||||
run: |
|
||||
chmod +x ./scripts/codesign_mac_app.sh
|
||||
./scripts/codesign_mac_app.sh "/tmp/eagle0/eagle0MAC/eagle0.app" "ci/mac/eagle0.entitlements"
|
||||
|
||||
- name: Submit for Notarization
|
||||
id: notarize-submit
|
||||
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
|
||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
chmod +x ./scripts/notarize_submit.sh
|
||||
./scripts/notarize_submit.sh "/tmp/eagle0/eagle0MAC/eagle0.app" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cleanup Keychain
|
||||
if: always()
|
||||
run: |
|
||||
security delete-keychain build.keychain 2>/dev/null || true
|
||||
|
||||
- name: Zip signed app for artifact
|
||||
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
|
||||
run: |
|
||||
cd /tmp/eagle0/eagle0MAC
|
||||
ditto -c -k --keepParent eagle0.app eagle0.app.zip
|
||||
|
||||
- name: Upload signed app
|
||||
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signed-mac-app
|
||||
path: /tmp/eagle0/eagle0MAC/eagle0.app.zip
|
||||
retention-days: 1
|
||||
|
||||
- name: Archive Build Log
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: editor_mac.log
|
||||
path: /tmp/eagle0/editor_mac.log
|
||||
|
||||
wait-notarization:
|
||||
needs: build-and-sign
|
||||
if: needs.build-and-sign.outputs.should_deploy == 'true'
|
||||
runs-on: [self-hosted, macOS, notarize]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: scripts
|
||||
|
||||
- name: Clean download directory
|
||||
run: rm -rf /tmp/eagle0/eagle0MAC
|
||||
|
||||
- name: Download signed app
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: signed-mac-app
|
||||
path: /tmp/eagle0/eagle0MAC
|
||||
|
||||
- name: Unzip signed app
|
||||
run: |
|
||||
cd /tmp/eagle0/eagle0MAC
|
||||
ditto -x -k eagle0.app.zip .
|
||||
rm eagle0.app.zip
|
||||
ls -la /tmp/eagle0/eagle0MAC/eagle0.app/
|
||||
|
||||
- name: Wait for Notarization and Staple
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
|
||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
chmod +x ./scripts/notarize_wait.sh
|
||||
./scripts/notarize_wait.sh "${{ needs.build-and-sign.outputs.submission_id }}" "/tmp/eagle0/eagle0MAC/eagle0.app"
|
||||
|
||||
- name: Zip notarized app for artifact
|
||||
run: |
|
||||
cd /tmp/eagle0/eagle0MAC
|
||||
rm -f eagle0.app.zip
|
||||
ditto -c -k --keepParent eagle0.app eagle0.app.zip
|
||||
|
||||
- name: Upload notarized app
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: notarized-mac-app
|
||||
path: /tmp/eagle0/eagle0MAC/eagle0.app.zip
|
||||
retention-days: 1
|
||||
|
||||
deploy:
|
||||
needs: [build-and-sign, wait-notarization]
|
||||
if: needs.build-and-sign.outputs.should_deploy == 'true'
|
||||
runs-on: [self-hosted, macOS, unity-mac]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # For version numbering
|
||||
|
||||
- name: Clean download directory
|
||||
run: rm -rf /tmp/eagle0/eagle0MAC
|
||||
|
||||
- name: Download notarized app
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: notarized-mac-app
|
||||
path: /tmp/eagle0/eagle0MAC
|
||||
|
||||
- name: Unzip notarized app
|
||||
run: |
|
||||
cd /tmp/eagle0/eagle0MAC
|
||||
ditto -x -k eagle0.app.zip .
|
||||
rm eagle0.app.zip
|
||||
|
||||
- name: Deploy Mac Build
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
SPARKLE_EDDSA_PRIVATE_KEY: ${{ secrets.SPARKLE_EDDSA_PRIVATE_KEY }}
|
||||
run: |
|
||||
# Write private key to temp file for signing
|
||||
SPARKLE_PRIVATE_KEY_PATH="/tmp/sparkle_private_key"
|
||||
echo "$SPARKLE_EDDSA_PRIVATE_KEY" > "$SPARKLE_PRIVATE_KEY_PATH"
|
||||
chmod 600 "$SPARKLE_PRIVATE_KEY_PATH"
|
||||
|
||||
VERSION=$(git describe --tags --always)
|
||||
BUILD_NUMBER=$(git rev-list --count HEAD)
|
||||
|
||||
bazel run //src/main/go/net/eagle0/build/mac_build_handler:mac_build_handler -- \
|
||||
"/tmp/eagle0/eagle0MAC/eagle0.app" \
|
||||
"$VERSION" \
|
||||
"$BUILD_NUMBER" \
|
||||
"$SPARKLE_PRIVATE_KEY_PATH"
|
||||
|
||||
rm "$SPARKLE_PRIVATE_KEY_PATH"
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Mac History Editor Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- ".github/workflows/mac_history_build.yml"
|
||||
- "src/main/swift/net/eagle0/EagleGameHistoryViewer/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/mac_history_build.yml"
|
||||
- "src/main/swift/net/eagle0/EagleGameHistoryViewer/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
mac-history-build:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
clean: false
|
||||
- name: Build the mac history
|
||||
run: ./ci/github_actions/build_mac_history.sh
|
||||
@@ -1,92 +0,0 @@
|
||||
name: Cleanup Old Container Images
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run daily at 3am UTC
|
||||
- cron: '0 3 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Dry run (show what would be deleted without deleting)'
|
||||
required: true
|
||||
default: 'true'
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install doctl
|
||||
uses: digitalocean/action-doctl@v2
|
||||
with:
|
||||
token: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
|
||||
- name: Cleanup old images
|
||||
env:
|
||||
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true' }}
|
||||
run: |
|
||||
set -e
|
||||
|
||||
RETENTION_DAYS=5
|
||||
CUTOFF_DATE=$(date -d "-${RETENTION_DAYS} days" +%s)
|
||||
REGISTRY="eagle0"
|
||||
|
||||
echo "Cleaning up images older than ${RETENTION_DAYS} days"
|
||||
echo "Cutoff date: $(date -d "@${CUTOFF_DATE}" -Iseconds)"
|
||||
echo "Dry run: ${DRY_RUN}"
|
||||
echo ""
|
||||
|
||||
# List of repositories to clean
|
||||
# Use tail to skip header row in case --no-header doesn't work
|
||||
REPOS=$(doctl registry repository list-v2 --format Name --no-header | grep -v '^Name$' | grep -v '^$')
|
||||
|
||||
for REPO in $REPOS; do
|
||||
echo "=== Processing repository: ${REPO} ==="
|
||||
|
||||
# Get all manifests with their tags and dates
|
||||
# Filter out header row and empty lines
|
||||
MANIFESTS=$(doctl registry repository list-manifests "${REPO}" --format Digest,UpdatedAt,Tags --no-header 2>/dev/null | grep -v '^Digest' | grep -v '^$' || echo "")
|
||||
|
||||
if [ -z "$MANIFESTS" ]; then
|
||||
echo " No manifests found"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "$MANIFESTS" | while read -r DIGEST UPDATED_AT TAGS; do
|
||||
# Skip if no digest or if it doesn't look like a valid digest (sha256:...)
|
||||
if [ -z "$DIGEST" ] || ! echo "$DIGEST" | grep -q '^sha256:'; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Parse the date
|
||||
MANIFEST_DATE=$(date -d "$UPDATED_AT" +%s 2>/dev/null || echo "0")
|
||||
|
||||
# Skip protected tags (latest, arm64-latest)
|
||||
if echo "$TAGS" | grep -qE '(^|,)(latest|arm64-latest)(,|$)'; then
|
||||
echo " KEEP: ${DIGEST:0:20}... (protected tag: $TAGS)"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if older than cutoff
|
||||
if [ "$MANIFEST_DATE" -lt "$CUTOFF_DATE" ]; then
|
||||
echo " DELETE: ${DIGEST:0:20}... (updated: $UPDATED_AT, tags: $TAGS)"
|
||||
if [ "$DRY_RUN" != "true" ]; then
|
||||
doctl registry repository delete-manifest "${REPO}" "$DIGEST" --force
|
||||
fi
|
||||
else
|
||||
echo " KEEP: ${DIGEST:0:20}... (updated: $UPDATED_AT, tags: $TAGS)"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
done
|
||||
|
||||
- name: Run garbage collection
|
||||
if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'false')
|
||||
run: |
|
||||
echo "Starting garbage collection..."
|
||||
doctl registry garbage-collection start --force
|
||||
echo "Garbage collection started. It may take a few minutes to complete."
|
||||
@@ -1,221 +0,0 @@
|
||||
name: Shardok ARM64 Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 'src/main/cpp/**'
|
||||
- 'src/main/protobuf/net/eagle0/shardok/**'
|
||||
- 'src/main/protobuf/net/eagle0/common/**'
|
||||
- 'src/main/resources/net/eagle0/shardok/**'
|
||||
- 'ci/BUILD.bazel'
|
||||
- 'MODULE.bazel'
|
||||
- '.github/workflows/shardok_arm64_build.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push_images:
|
||||
description: 'Push images to container registry'
|
||||
required: true
|
||||
default: 'true'
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-shardok-arm64:
|
||||
runs-on: [self-hosted, bazel]
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Build Shardok ARM64 binary (cross-compile for Linux ARM64)
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
echo "=== Building shardok-server binary for linux-aarch64 ==="
|
||||
bazel build \
|
||||
--platforms=//:linux_arm64 \
|
||||
--extra_toolchains=@llvm_toolchain_linux_arm64//:all \
|
||||
//src/main/cpp/net/eagle0/shardok:shardok-server
|
||||
|
||||
LINUX_BIN="bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server"
|
||||
echo "=== Checking binary at: $LINUX_BIN ==="
|
||||
|
||||
if [ ! -f "$LINUX_BIN" ]; then
|
||||
echo "ERROR: Binary not found at $LINUX_BIN"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify it's ELF (Linux) not Mach-O (macOS)
|
||||
echo "=== Verifying binary format ==="
|
||||
MAGIC=$(head -c 4 "$LINUX_BIN" | xxd -p)
|
||||
echo "Binary magic bytes: $MAGIC"
|
||||
|
||||
if [ "$MAGIC" = "7f454c46" ]; then
|
||||
echo "SUCCESS: Binary is ELF format (Linux)"
|
||||
# Check if it's ARM64 (e_machine = 0xB7 = 183 for aarch64)
|
||||
E_MACHINE=$(od -An -j18 -N2 -tx2 "$LINUX_BIN" | tr -d ' ')
|
||||
echo "ELF e_machine: $E_MACHINE"
|
||||
if [ "$E_MACHINE" = "b700" ]; then
|
||||
echo "SUCCESS: Binary is ARM64 (aarch64)"
|
||||
else
|
||||
echo "WARNING: Binary e_machine is $E_MACHINE (expected b700 for aarch64)"
|
||||
fi
|
||||
elif [ "$MAGIC" = "cfaeedfe" ] || [ "$MAGIC" = "cffaedfe" ]; then
|
||||
echo "ERROR: Binary is Mach-O format (macOS) - cross-compilation failed!"
|
||||
exit 1
|
||||
else
|
||||
echo "WARNING: Unknown binary format: $MAGIC"
|
||||
file "$LINUX_BIN" || true
|
||||
fi
|
||||
|
||||
- name: Build Shardok ARM64 Docker image
|
||||
id: build-shardok
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
bazel build \
|
||||
--platforms=//:linux_arm64 \
|
||||
--extra_toolchains=@llvm_toolchain_linux_arm64//:all \
|
||||
//ci:shardok_server_image_arm64
|
||||
|
||||
IMAGE_PATH=$(readlink -f bazel-bin/ci/shardok_server_image_arm64)
|
||||
echo "Image path: $IMAGE_PATH"
|
||||
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
# Verify the binary inside the tar layer is ARM64 ELF
|
||||
echo "=== Verifying binary in image tar ==="
|
||||
BINARY_TAR="bazel-bin/ci/shardok_binary_layer_arm64.tar"
|
||||
if [ -f "$BINARY_TAR" ]; then
|
||||
echo "Checking binary in $BINARY_TAR"
|
||||
MAGIC=$(tar -xOf "$BINARY_TAR" app/shardok-server 2>/dev/null | head -c 4 | xxd -p)
|
||||
echo "Binary magic in tar: $MAGIC"
|
||||
if [ "$MAGIC" = "7f454c46" ]; then
|
||||
echo "SUCCESS: Binary in tar is ELF format (Linux)"
|
||||
else
|
||||
echo "ERROR: Binary in tar is NOT ELF format!"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "WARNING: Could not find $BINARY_TAR"
|
||||
fi
|
||||
|
||||
- name: Login to DigitalOcean Container Registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
run: |
|
||||
AUTH=$(echo -n "${DO_REGISTRY_TOKEN}:${DO_REGISTRY_TOKEN}" | base64)
|
||||
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
|
||||
- name: Push Shardok ARM64 image to DigitalOcean
|
||||
id: push-shardok
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
CROSS_IMAGE="${{ steps.build-shardok.outputs.image_path }}"
|
||||
echo "Using cross-compiled image: $CROSS_IMAGE"
|
||||
|
||||
if [ -z "$CROSS_IMAGE" ] || [ ! -d "$CROSS_IMAGE" ]; then
|
||||
echo "ERROR: Cross-compiled image not found at: $CROSS_IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build a push target to get crane in runfiles
|
||||
bazel build //ci:eagle_server_push
|
||||
|
||||
# Find the Darwin crane binary
|
||||
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
|
||||
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
|
||||
if [ -z "$CRANE" ]; then
|
||||
CRANE=$(find "$RUNFILES" -name crane 2>/dev/null | head -1)
|
||||
fi
|
||||
if [ -z "$CRANE" ] || [ ! -e "$CRANE" ]; then
|
||||
echo "ERROR: crane not found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Using crane: $CRANE"
|
||||
|
||||
# Push with arm64-prefixed SHA tag (same repo as x86, different tag)
|
||||
GIT_SHA=$(git rev-parse --short=8 HEAD)
|
||||
IMAGE_TAG="registry.digitalocean.com/eagle0/shardok-server:arm64-${GIT_SHA}"
|
||||
echo "Pushing shardok ARM64 image: $IMAGE_TAG"
|
||||
$CRANE push "$CROSS_IMAGE" "$IMAGE_TAG"
|
||||
|
||||
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Also update :arm64-latest tag for convenience
|
||||
echo "Copying to :arm64-latest tag"
|
||||
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/shardok-server:arm64-latest"
|
||||
|
||||
echo "=== Push complete ==="
|
||||
echo "Image: $IMAGE_TAG"
|
||||
echo "Also tagged as: registry.digitalocean.com/eagle0/shardok-server:arm64-latest"
|
||||
|
||||
deploy-hetzner:
|
||||
runs-on: [self-hosted, bazel]
|
||||
needs: [build-shardok-arm64]
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
environment: production
|
||||
env:
|
||||
SHARDOK_IMAGE: ${{ needs.build-shardok-arm64.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.HETZNER_SSH_KEY }}" > ~/.ssh/hetzner_deploy
|
||||
chmod 600 ~/.ssh/hetzner_deploy
|
||||
# Add host key to known_hosts to avoid prompt
|
||||
ssh-keyscan -H ${{ secrets.HETZNER_IP }} >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
- name: Deploy to Hetzner
|
||||
run: |
|
||||
ssh -i ~/.ssh/hetzner_deploy -o StrictHostKeyChecking=accept-new deploy@${{ secrets.HETZNER_IP }} << 'ENDSSH'
|
||||
set -ex
|
||||
cd /opt/eagle0
|
||||
|
||||
# Login to registry
|
||||
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
|
||||
|
||||
echo "Deploying Shardok ARM64: ${{ needs.build-shardok-arm64.outputs.image_tag }}"
|
||||
|
||||
# Pull the new image
|
||||
docker pull "${{ needs.build-shardok-arm64.outputs.image_tag }}"
|
||||
|
||||
# Stop and remove any container using port 40042 or named shardok*
|
||||
docker ps -q --filter "publish=40042" | xargs -r docker stop
|
||||
docker ps -aq --filter "name=shardok" | xargs -r docker rm -f
|
||||
docker ps -aq --filter "publish=40042" | xargs -r docker rm -f
|
||||
|
||||
# Run new container
|
||||
docker run -d \
|
||||
--name shardok-ai \
|
||||
--restart unless-stopped \
|
||||
-p 40042:40042 \
|
||||
-v /opt/eagle0/data:/data \
|
||||
-v /etc/shardok:/etc/shardok:ro \
|
||||
-v /etc/letsencrypt:/etc/letsencrypt:ro \
|
||||
-v /usr/local/share/eagle0:/usr/local/share/eagle0:ro \
|
||||
-e SHARDOK_RESOURCES_PATH=/app/resources \
|
||||
-e SHARDOK_MAPS_PATH=/app/resources/maps \
|
||||
"${{ needs.build-shardok-arm64.outputs.image_tag }}"
|
||||
|
||||
# Wait and verify
|
||||
sleep 5
|
||||
docker ps | grep shardok-ai
|
||||
|
||||
# Cleanup old images
|
||||
docker image prune -f
|
||||
|
||||
echo "=== Hetzner deployment complete ==="
|
||||
ENDSSH
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/hetzner_deploy
|
||||
@@ -28,7 +28,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: [self-hosted, bazel]
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -6,11 +6,7 @@ on:
|
||||
paths:
|
||||
- ".github/workflows/unity_build.yml"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/**"
|
||||
- "src/main/protobuf/net/eagle0/common/**"
|
||||
- "src/main/protobuf/net/eagle0/shardok/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/api/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/common/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/views/**"
|
||||
- "src/main/proto/**"
|
||||
- "scripts/build_protos.sh"
|
||||
- "scripts/build_plugins.sh"
|
||||
- "scripts/build_windows_plugin.sh"
|
||||
@@ -19,16 +15,12 @@ on:
|
||||
- "ci/github_actions/persist_library.sh"
|
||||
- "MODULE.bazel"
|
||||
- "WORKSPACE"
|
||||
workflow_dispatch:
|
||||
- "src/main/proto/net/eagle0/eagle/**/BUILD.bazel"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/unity_build.yml"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/**"
|
||||
- "src/main/protobuf/net/eagle0/common/**"
|
||||
- "src/main/protobuf/net/eagle0/shardok/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/api/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/common/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/views/**"
|
||||
- "src/main/proto/**"
|
||||
- "scripts/build_protos.sh"
|
||||
- "scripts/build_plugins.sh"
|
||||
- "scripts/build_windows_plugin.sh"
|
||||
@@ -37,13 +29,14 @@ on:
|
||||
- "ci/github_actions/persist_library.sh"
|
||||
- "MODULE.bazel"
|
||||
- "WORKSPACE"
|
||||
- "src/main/proto/**/BUILD.bazel"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
windows-unity:
|
||||
runs-on: [self-hosted, macOS, unity-windows]
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -59,35 +52,18 @@ jobs:
|
||||
- name: Persist Library/
|
||||
run: ./ci/github_actions/persist_library.sh
|
||||
- name: Deploy Windows unity
|
||||
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
if: success() #&& github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: bazel run //src/main/go/net/eagle0/build/unity3d_windows_build_handler:unity3d_windows_build_handler -- "/tmp/eagle0/eagle0WIN" "/tmp/unity_manifest.txt"
|
||||
|
||||
- name: Update unified manifest
|
||||
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
if: success() #&& github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
|
||||
run: |
|
||||
# Write signing key to temp file (if available)
|
||||
SIGNING_ARGS=""
|
||||
if [ -n "$MANIFEST_SIGNING_KEY" ]; then
|
||||
echo "$MANIFEST_SIGNING_KEY" > /tmp/manifest_signing_key
|
||||
chmod 600 /tmp/manifest_signing_key
|
||||
SIGNING_ARGS="/tmp/manifest_signing_key"
|
||||
echo "Manifest signing key available"
|
||||
else
|
||||
echo "Warning: MANIFEST_SIGNING_KEY not set, manifest will be unsigned"
|
||||
fi
|
||||
|
||||
# Update the unified manifest (with optional signing)
|
||||
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- unity3d /tmp/unity_manifest.txt $SIGNING_ARGS
|
||||
|
||||
# Cleanup
|
||||
rm -f /tmp/manifest_signing_key
|
||||
run: bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- unity3d /tmp/unity_manifest.txt
|
||||
- name: Archive build log
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
+2
-3
@@ -20,7 +20,7 @@ project/boot/
|
||||
project/plugins/project/
|
||||
project/target/
|
||||
bazel-bin
|
||||
bazel-eagle0*
|
||||
bazel-eagle0
|
||||
bazel-out
|
||||
bazel-testlogs
|
||||
.ijwb
|
||||
@@ -32,10 +32,9 @@ buildWin.sh
|
||||
__pycache__/
|
||||
scripts/refresh_name_layers/vendor/
|
||||
scripts/refresh_name_layers/refresh_name_layers.zip
|
||||
.pre-commit-config.yaml
|
||||
.bazelbsp
|
||||
.bsp
|
||||
.metals
|
||||
api_keys.txt
|
||||
|
||||
src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/Packages/com.unity.dedicated-server/
|
||||
node_modules/
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# See https://pre-commit.com for more information
|
||||
# See https://pre-commit.com/hooks.html for more hooks
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.3.0
|
||||
hooks:
|
||||
- id: check-added-large-files
|
||||
- id: no-commit-to-branch
|
||||
args: [--branch, main]
|
||||
- repo: https://github.com/pocc/pre-commit-hooks
|
||||
rev: v1.3.5
|
||||
hooks:
|
||||
- id: clang-format
|
||||
args: [-i, --no-diff]
|
||||
types_or: ["c++", "c#"]
|
||||
exclude: ^src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins
|
||||
- repo: https://github.com/yoheimuta/protolint
|
||||
rev: v0.42.2
|
||||
hooks:
|
||||
- id: protolint
|
||||
args: [-fix]
|
||||
exclude: ^src/main/protobuf/scalapb/
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: scalafmt
|
||||
name: scalafmt
|
||||
language: system
|
||||
entry: scalafmt -i -f
|
||||
types_or: ["scala"]
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: gazelle
|
||||
name: gazelle
|
||||
language: system
|
||||
entry: ./scripts/pre-commit-gazelle.sh
|
||||
files: '(\.go|\.proto|BUILD\.bazel|BUILD|WORKSPACE|WORKSPACE\.bazel|\.bzl)$'
|
||||
pass_filenames: false
|
||||
+2
-47
@@ -1,47 +1,2 @@
|
||||
version = "3.9.9"
|
||||
runner.dialect = scala3
|
||||
rewrite.scala3.convertToNewSyntax = true
|
||||
# Keep braces, don't use significant indentation
|
||||
# rewrite.scala3.removeOptionalBraces = yes
|
||||
rewrite.scala3.insertEndMarkerMinLines = 15
|
||||
rewrite.scala3.removeEndMarkerMaxLines = 14
|
||||
|
||||
# Strip margin settings
|
||||
assumeStandardLibraryStripMargin = false
|
||||
align.stripMargin = true
|
||||
|
||||
# Code Style & Formatting
|
||||
align.preset = more
|
||||
align.multiline = true
|
||||
align.arrowEnumeratorGenerator = true
|
||||
spaces.inImportCurlyBraces = false
|
||||
spaces.beforeContextBoundColon = Never
|
||||
maxColumn = 120
|
||||
docstrings.style = Asterisk
|
||||
docstrings.wrap = yes
|
||||
|
||||
# Method chaining
|
||||
newlines.beforeCurlyLambdaParams = multilineWithCaseOnly
|
||||
optIn.breakChainOnFirstMethodDot = true
|
||||
includeCurlyBraceInSelectChains = false
|
||||
|
||||
# Advanced Scala 3 Features
|
||||
rewrite.scala3.countEndMarkerLines = all
|
||||
rewrite.redundantBraces.stringInterpolation = true
|
||||
rewrite.redundantBraces.parensForOneLineApply = true
|
||||
|
||||
# Project-Specific Considerations
|
||||
optIn.annotationNewlines = true
|
||||
runner.optimizer.forceConfigStyleMinArgCount = 3
|
||||
|
||||
# Import sorting configuration
|
||||
rewrite.rules = [SortImports, RedundantBraces, RedundantParens]
|
||||
rewrite.imports.sort = scalastyle
|
||||
rewrite.imports.groups = [
|
||||
["java\\..*"],
|
||||
["javax\\..*"],
|
||||
["scala\\..*"],
|
||||
[".*"]
|
||||
]
|
||||
rewrite.imports.contiguousGroups = only
|
||||
rewrite.trailingCommas.style = never
|
||||
version = "3.6.1"
|
||||
runner.dialect = scala213
|
||||
|
||||
-18
@@ -3,24 +3,6 @@ load("@io_bazel_rules_go//go:def.bzl", "nogo")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
# Platform for cross-compiling to Linux x86_64
|
||||
platform(
|
||||
name = "linux_x86_64",
|
||||
constraint_values = [
|
||||
"@platforms//os:linux",
|
||||
"@platforms//cpu:x86_64",
|
||||
],
|
||||
)
|
||||
|
||||
# Platform for cross-compiling to Linux ARM64
|
||||
platform(
|
||||
name = "linux_arm64",
|
||||
constraint_values = [
|
||||
"@platforms//os:linux",
|
||||
"@platforms//cpu:aarch64",
|
||||
],
|
||||
)
|
||||
|
||||
gazelle(name = "gazelle")
|
||||
|
||||
# gazelle:proto file
|
||||
|
||||
@@ -1,53 +1,29 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## CRITICAL GIT RULES (NEVER VIOLATE)
|
||||
|
||||
**NEVER push directly to main/master.** No exceptions. Not for "small changes." Not for docs. Not ever.
|
||||
|
||||
**NEVER merge PRs.** You create PRs. The user merges them. No exceptions.
|
||||
|
||||
**ALWAYS use this workflow:**
|
||||
1. Create a feature branch from origin/main
|
||||
2. Commit to that branch
|
||||
3. Create a PR with `gh pr create`
|
||||
4. Wait for user to merge (DO NOT run `gh pr merge`)
|
||||
|
||||
If you catch yourself about to run `git push origin main` or `git push origin <branch>:main`, STOP. You are about to violate a critical rule. Create a PR instead.
|
||||
|
||||
If you catch yourself about to run `gh pr merge`, STOP. Only the user merges PRs.
|
||||
|
||||
---
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Eagle0 is a multi-language gaming system combining strategic turn-based gameplay (Eagle) with tactical hex-based
|
||||
combat (Shardok). The system integrates LLM-based narrative generation and supports both human and AI players.
|
||||
Eagle0 is a multi-language gaming system combining strategic turn-based gameplay (Eagle) with tactical hex-based combat (Shardok). The system integrates LLM-based narrative generation and supports both human and AI players.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Three-Tier Game System:**
|
||||
|
||||
- **Unity Client (C#)**: Real-time strategy game client with integrated tactical combat UI
|
||||
- **Eagle (Scala)**: Strategic layer managing turn-based gameplay, diplomacy, hero progression, and province control
|
||||
- **Shardok (C++)**: Tactical layer handling real-time hex-based combat simulation with performance-critical battle
|
||||
resolution
|
||||
- **Shardok (C++)**: Tactical layer handling real-time hex-based combat simulation with performance-critical battle resolution
|
||||
|
||||
**Communication Flow:**
|
||||
|
||||
```
|
||||
Unity Client ↔ Eagle (gRPC streaming) ↔ Shardok (internal gRPC)
|
||||
```
|
||||
|
||||
**Key Entry Points:**
|
||||
|
||||
- `/src/main/csharp/net/eagle0/clients/unity/eagle0/` - Unity C# game client
|
||||
- `/src/main/scala/net/eagle0/eagle/Main.scala` - Eagle strategic game server
|
||||
- `/src/main/cpp/net/eagle0/shardok/shardok_server_main.cpp` - Shardok tactical server
|
||||
|
||||
**Protocol Buffer Architecture:**
|
||||
|
||||
- Extensive use of protobuf for type-safe communication
|
||||
- Separate packages: `api/` (client-facing), `internal/` (server state), `views/` (client projections)
|
||||
- Event sourcing pattern with immutable action history
|
||||
@@ -55,17 +31,13 @@ Unity Client ↔ Eagle (gRPC streaming) ↔ Shardok (internal gRPC)
|
||||
## Essential Commands
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
# Build Eagle server (Scala strategic layer)
|
||||
bazel build //src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar
|
||||
|
||||
# Build Shardok server (C++ tactical layer)
|
||||
# Build Shardok server (C++ tactical layer)
|
||||
bazel build -c opt //src/main/cpp/net/eagle0/shardok:shardok-server
|
||||
|
||||
# Shardok server includes both AI algorithms
|
||||
bazel build //src/main/cpp/net/eagle0/shardok:shardok-server
|
||||
|
||||
# Build Unity/C# client
|
||||
./scripts/build_protos.sh # Protocol buffer generation for Unity
|
||||
./scripts/build_plugins.sh # Native plugins for all platforms
|
||||
@@ -74,7 +46,6 @@ bazel build //src/main/cpp/net/eagle0/shardok:shardok-server
|
||||
```
|
||||
|
||||
### Running Services
|
||||
|
||||
```bash
|
||||
# Eagle server (port 40032)
|
||||
bazel run //src/main/scala/net/eagle0/eagle:eagle_server -- --eagle-grpc-port 40032
|
||||
@@ -86,7 +57,6 @@ bazel run //src/main/cpp/net/eagle0/shardok:shardok-server --compilation_mode=op
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
bazel test //src/test/... //src/main/go/...
|
||||
@@ -97,24 +67,12 @@ bazel test //src/test/cpp/... # C++ Shardok tests
|
||||
```
|
||||
|
||||
### Code Generation
|
||||
|
||||
```bash
|
||||
bazel run gazelle # Update Go build files
|
||||
./scripts/updateActionResultTypes.sh # Update protocol buffer mappings
|
||||
```
|
||||
|
||||
### Pre-Commit Checklist
|
||||
|
||||
**MANDATORY: Before running `git commit`, verify:**
|
||||
|
||||
1. **If you modified any BUILD.bazel file:** Run `bazel run gazelle` and stage any changes it makes
|
||||
2. **If you modified C++ or C# files:** Run `clang-format -i` on the modified files
|
||||
3. **If you modified Scala files:** scalafmt will run automatically via pre-commit hook
|
||||
|
||||
The pre-commit hook runs gazelle but only checks if it succeeds - it does NOT verify the BUILD files are in canonical format. The `gazelle_test` will fail if deps are not alphabetically sorted. **Always run gazelle manually after BUILD file changes.**
|
||||
|
||||
### Code Formatting
|
||||
|
||||
```bash
|
||||
# ALWAYS run clang-format after making any C++ or C# code changes
|
||||
clang-format -i <modified_files>
|
||||
@@ -126,95 +84,26 @@ find . -name "*.cpp" -o -name "*.hpp" | xargs clang-format -i
|
||||
find . -name "*.cs" | xargs clang-format -i
|
||||
```
|
||||
|
||||
### Static Analysis
|
||||
|
||||
```bash
|
||||
# Run clang-tidy static analysis on C++ files
|
||||
# Note: This may show some header include errors but will still analyze the main file
|
||||
bazel run @llvm_toolchain//:clang-tidy -- --checks='readability-*,bugprone-*,clang-analyzer-*' <file_path> -- -I/Users/dancrosby/CodingProjects/github/eagle0 -std=c++23
|
||||
|
||||
# Example for AI files:
|
||||
bazel run @llvm_toolchain//:clang-tidy -- --checks='readability-*,bugprone-*,clang-analyzer-*' /Users/dancrosby/CodingProjects/github/eagle0/src/main/cpp/net/eagle0/shardok/ai/AIUnitScoreCalculator.cpp -- -I/Users/dancrosby/CodingProjects/github/eagle0 -std=c++23
|
||||
```
|
||||
|
||||
## AI Algorithm Selection
|
||||
|
||||
Eagle0 supports two AI algorithms for tactical combat decision-making:
|
||||
|
||||
### Iterative Deepening AI (Default)
|
||||
|
||||
The original minimax-based AI with sophisticated randomness handling:
|
||||
|
||||
- **Advantages**: Proven, sophisticated randomness evaluation, comprehensive lookahead
|
||||
- **Use cases**: Production builds, scenarios requiring precise evaluation
|
||||
- **Performance**: Single-threaded, thorough evaluation
|
||||
|
||||
### Monte Carlo Tree Search AI (MCTS)
|
||||
|
||||
Modern MCTS-based AI with multithreading support:
|
||||
|
||||
- **Advantages**: Multithreaded, better performance on modern CPUs, anytime algorithm
|
||||
- **Use cases**: Performance testing, scenarios requiring fast decisions
|
||||
- **Performance**: Multithreaded, adaptive depth based on time budget
|
||||
|
||||
### Switching Between Algorithms
|
||||
|
||||
The algorithm is selected at **runtime** via the ShardokAIClient constructor:
|
||||
|
||||
```cpp
|
||||
// Using Iterative Deepening AI (default)
|
||||
ShardokAIClient client(playerId, isDefender, hexMap, settings);
|
||||
// OR explicitly:
|
||||
ShardokAIClient client(playerId, isDefender, hexMap, settings, AIAlgorithmType::ITERATIVE_DEEPENING);
|
||||
|
||||
// Using MCTS AI
|
||||
ShardokAIClient client(playerId, isDefender, hexMap, settings, AIAlgorithmType::MCTS);
|
||||
```
|
||||
|
||||
```bash
|
||||
# Build the server (includes both AI algorithms)
|
||||
bazel build //src/main/cpp/net/eagle0/shardok:shardok-server
|
||||
|
||||
# Test both algorithms
|
||||
bazel test //src/test/cpp/net/eagle0/shardok/ai:ai_iterative_deepening_test
|
||||
bazel test //src/test/cpp/net/eagle0/shardok/ai:ai_mcts_test # If available
|
||||
|
||||
# Performance tests
|
||||
./scripts/ai_perf_test.sh # Uses whatever algorithm the server is configured to use
|
||||
```
|
||||
|
||||
Both implementations are compatible with all existing interfaces and produce the same `SearchResult` structure.
|
||||
|
||||
**Note**: Both implementations are documented in `src/main/cpp/net/eagle0/shardok/ai/AI_SCORING_SYSTEM.md`, including
|
||||
recommendations for improving MCTS randomness handling.
|
||||
|
||||
The AI algorithm selection is made at runtime when creating ShardokAIClient instances, allowing different AI strategies
|
||||
to be used for different players or game situations within the same server process.
|
||||
|
||||
## Language-Specific Patterns
|
||||
|
||||
**Scala (Strategic Layer):**
|
||||
|
||||
- Use `EngineImpl.scala` for core game logic modifications
|
||||
- Follow event sourcing pattern - all changes through immutable actions
|
||||
- gRPC streaming for real-time client updates via `EagleServiceImpl.scala`
|
||||
- LLM integration in `/common/llm_integration/` for narrative generation
|
||||
|
||||
**C++ (Tactical Layer):**
|
||||
|
||||
- Performance-critical combat in `ShardokEngine.hpp/.cpp`
|
||||
- FlatBuffers for efficient serialization in `/flatbuffer/` directory
|
||||
- AI systems in `/ai/` subdirectory with pluggable strategy selectors
|
||||
- Extensive unit testing with Google Test framework
|
||||
|
||||
**Protocol Buffers:**
|
||||
|
||||
- Three-layer structure: `api/` (client), `internal/` (server), `views/` (projections)
|
||||
- Use `shardok_internal_interface.proto` for Eagle-Shardok communication
|
||||
- Maintain backward compatibility when modifying existing messages
|
||||
|
||||
**C# (Unity Client):**
|
||||
|
||||
- Located in `/src/main/csharp/net/eagle0/clients/unity/eagle0/`
|
||||
- Uses Unity 6 (6000.0.32f1) with comprehensive protobuf integration (100+ .proto files)
|
||||
- Key components: `EagleConnection.cs` (gRPC client), `EagleGameController.cs` (main game logic)
|
||||
@@ -223,7 +112,6 @@ to be used for different players or game situations within the same server proce
|
||||
- Seamless transition between strategic gameplay and hex-based tactical combat
|
||||
|
||||
**Go (Build Tools):**
|
||||
|
||||
- Build automation and code generation utilities
|
||||
- AWS S3 integration for deployment artifacts
|
||||
|
||||
@@ -234,31 +122,6 @@ to be used for different players or game situations within the same server proce
|
||||
- Map validation tests ensure game content integrity
|
||||
- Use `GameSettings_test_utils.cpp` and `ShardokEngineBasedTestData.cpp` for C++ test helpers
|
||||
|
||||
### Scala Testing Patterns
|
||||
|
||||
**Use `inside()` instead of `asInstanceOf` for type matching in tests:**
|
||||
|
||||
Never use `asInstanceOf` in tests. Instead, use ScalaTest's `inside()` pattern for safe type matching:
|
||||
|
||||
```scala
|
||||
// BAD - don't do this
|
||||
val changedHero = result.changedHeroes.head.asInstanceOf[ChangedHeroC]
|
||||
changedHero.heroId shouldBe 19
|
||||
|
||||
// GOOD - use inside() pattern
|
||||
import org.scalatest.Inside.inside
|
||||
|
||||
inside(result.changedHeroes.head) { case changedHero: ChangedHeroC =>
|
||||
changedHero.heroId shouldBe 19
|
||||
changedHero.vigorChange shouldBe StatDelta(17.2)
|
||||
}
|
||||
```
|
||||
|
||||
The `inside()` pattern:
|
||||
- Provides better error messages when the type doesn't match
|
||||
- Is idiomatic ScalaTest
|
||||
- Works with pattern matching for more complex assertions
|
||||
|
||||
## Performance Testing
|
||||
|
||||
When making performance-related changes to the AI or engine:
|
||||
@@ -290,38 +153,10 @@ done
|
||||
```
|
||||
|
||||
**Important notes:**
|
||||
|
||||
- Run tests multiple times (3-5) to account for performance variance
|
||||
- Focus on commands evaluated at each depth rather than total commands
|
||||
- Commands at different depths aren't directly comparable (depth 3 is more valuable than depth 2)
|
||||
- **Always test performance changes** - what seems like an optimization may sometimes have unexpected overhead or
|
||||
behavior changes.
|
||||
|
||||
## Troubleshooting Scala Build Errors
|
||||
|
||||
### MissingType Errors
|
||||
|
||||
When you see errors like:
|
||||
```
|
||||
dotty.tools.dotc.core.MissingType: Cannot resolve reference to type net.eagle0.eagle.internal.game_state.type.GameState
|
||||
```
|
||||
|
||||
**This is NOT a Scala compiler crash.** This is a missing dependency in BUILD.bazel.
|
||||
|
||||
**How to fix:**
|
||||
1. Identify the missing type from the error message (e.g., `game_state.GameState`)
|
||||
2. Find the Bazel target that provides this type (e.g., `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`)
|
||||
3. Add it to the `deps` of the failing target
|
||||
4. If the type appears in a public method signature, also add it to `exports` so downstream targets can see it
|
||||
|
||||
**Common pattern:** When adding a method to a class that takes or returns a proto type, the proto dependency often needs to be added to both `deps` AND `exports`.
|
||||
|
||||
### Bazel Clean
|
||||
|
||||
**NEVER run `bazel clean` without asking first.** It rarely fixes actual issues and wastes significant rebuild time. The issues that seem like they need `bazel clean` are usually:
|
||||
- Missing imports in Scala code
|
||||
- Missing dependencies in BUILD.bazel
|
||||
- Missing exports for types used in public signatures
|
||||
- **Always test performance changes** - what seems like an optimization may sometimes have unexpected overhead or behavior changes.
|
||||
|
||||
## Game Content
|
||||
|
||||
@@ -333,6 +168,4 @@ dotty.tools.dotc.core.MissingType: Cannot resolve reference to type net.eagle0.e
|
||||
|
||||
- Bazel handles multi-language builds and dependencies
|
||||
- CI/CD via GitHub Actions with platform-specific build scripts in `/ci/github_actions/`
|
||||
- Docker containerization available via `ci/eagle_run.Dockerfile`
|
||||
- Always run "bazel run //:gazelle" after editing any BUILD.bazel files
|
||||
- *ALWAYS ALWAYS* run "bazel run gazelle" after any change that modifies a BUILD.bazel file
|
||||
- Docker containerization available via `ci/eagle_run.Dockerfile`
|
||||
-213
@@ -1,213 +0,0 @@
|
||||
# Deproto Migration Plan
|
||||
|
||||
This document tracks the migration from protobuf types to native Scala models inside the Eagle game engine.
|
||||
|
||||
## Architectural Decisions
|
||||
|
||||
1. **Keep proto for persistence**: Yes - protobuf is used for persisting game state
|
||||
2. **Keep proto for Shardok communication**: Yes - protobuf is used for Eagle-Shardok gRPC communication
|
||||
3. **Use Scala views inside the library**: Yes - use native Scala types like `ProvinceView`, `FactionView`, `HeroView`, etc. within the library code
|
||||
|
||||
## Recent Completed Work
|
||||
|
||||
### GameState Round-Trip Elimination (PRs #4913, #4914, #4915)
|
||||
|
||||
Eliminated wasteful Scala→proto→Scala conversions in the hot path:
|
||||
|
||||
1. **LLM Pipeline** (#4913): `LlmRequestWithGameState` now uses Scala `GameState` instead of proto. All ~38 prompt generators updated to use Scala model types (`FactionT`, `HeroT`, `ProvinceT`).
|
||||
|
||||
2. **ActionWithResultingState Caching** (#4914): Added `precomputedScalaState: Option[GameState]` to cache Scala state when available, avoiding `fromProto()` conversion in `stateAfter()`.
|
||||
|
||||
3. **PostResults Simplification** (#4915): Changed `PostResults.gameState` from proto to `Option[GameState]` (Scala), eliminating `toProto()` calls when creating PostResults.
|
||||
|
||||
## Migration Pattern
|
||||
|
||||
The codebase follows a **Legacy* pattern** for separating proto-dependent and protoless code:
|
||||
|
||||
- **Protoless utilities**: `FactionUtils`, `HeroUtils`, `ProvinceUtils`, `ProvinceDistances`, etc.
|
||||
- **Proto-dependent utilities**: `LegacyFactionUtils`, `LegacyHeroUtils`, `LegacyProvinceUtils`, `LegacyProvinceDistances`, etc.
|
||||
|
||||
When migrating a file:
|
||||
1. Create a `Legacy*` version containing the proto-dependent methods
|
||||
2. Keep the original file name for protoless methods
|
||||
3. Update callers to use the appropriate version based on their context
|
||||
|
||||
## Migration Status
|
||||
|
||||
### Fully Protoless (no proto imports)
|
||||
|
||||
**Utilities:**
|
||||
- [x] `FactionUtils` - has protoless `ownedNeighbors` method
|
||||
- [x] `ProvinceDistances` - split into protoless + `LegacyProvinceDistances`
|
||||
- [x] `SwornBrotherChooser` - fully protoless (removed `bestChoiceProto`)
|
||||
|
||||
**Command Selectors (all use native GameState):**
|
||||
- [x] `AllianceOfferCommandSelector`
|
||||
- [x] `AlmsCommandSelector`
|
||||
- [x] `AttackCommandChooser`
|
||||
- [x] `ExpandCommandSelector`
|
||||
- [x] `HeroGiftCommandSelector`
|
||||
- [x] `ImproveCommandSelector`
|
||||
- [x] `MarchTowardProvinceCommandChooser` - in AI folder, uses native GameState (callers convert)
|
||||
- [x] `OrganizeCommandSelector`
|
||||
- [x] `RansomOfferHelpers`
|
||||
- [x] `SeekMoreLeadersCommandChooser` - in AI folder, uses native GameState
|
||||
- [x] `TruceOfferCommandSelector`
|
||||
- [x] `TrustForDiplomacy`
|
||||
|
||||
**Quest Command Selectors (all protoless):**
|
||||
- [x] `AllianceQuestCommandChooser`
|
||||
- [x] `AlmsAcrossRealmQuestCommandChooser`
|
||||
- [x] `AlmsToProvinceQuestCommandChooser`
|
||||
- [x] `DismissSpecificVassalCommandChooser`
|
||||
- [x] `GiveToHeroesAcrossRealmQuestCommandChooser`
|
||||
- [x] `GiveToHeroesInProvinceQuestCommandChooser`
|
||||
- [x] `ImproveQuestCommandChooser`
|
||||
- [x] `QuestCommandChooser`
|
||||
- [x] `TruceCountQuestCommandChooser`
|
||||
- [x] `TruceWithFactionQuestCommandChooser`
|
||||
|
||||
### Fully Protoless
|
||||
|
||||
- [x] `AIClientUtils` - has protoless overloads (`takenHeroIdsForMarchTowardFocus`, `mostPowerfulHeroes`)
|
||||
- [x] `AttackCommandChooser` - uses Scala `GameState` and `BattalionViewC` (Scala view type)
|
||||
- [x] `BattalionPower` - has `estimatedPower(BattalionViewC)` for recon data with optional stats
|
||||
- [x] `CommandChoiceHelpers` - fully protoless, uses Scala `GameState` throughout
|
||||
- [x] `ProvinceGoldSurplusCalculator` - fully protoless (callers use converters)
|
||||
- [x] `HeroSelector` - fully protoless (removed dead `minimallyFatiguedHeroesProto`)
|
||||
|
||||
### AI Layer ✅ COMPLETE
|
||||
|
||||
All AI and command chooser code is now fully protoless:
|
||||
- [x] `AttackDecisionCommandChooser` - uses Scala GameState
|
||||
- [x] `CommandChooser` - trait uses Scala GameState
|
||||
- [x] `FulfillQuestsCommandSelector` - uses Scala GameState
|
||||
- [x] `MidGameAIClient` - uses Scala GameState internally
|
||||
|
||||
### Still Using Proto GameState (Boundary Code)
|
||||
|
||||
These files use proto GameState because they're at system boundaries:
|
||||
|
||||
**View Filters (client projection):**
|
||||
- `view_filters/GameStateViewFilter` - has Scala overload, uses Scala sub-filters
|
||||
- `view_filters/ProvinceViewFilter` - has Scala overloads for some methods
|
||||
- `view_filters/FactionViewFilter` - has Scala overload
|
||||
- `view_filters/HeroViewFilter` - has Scala overload
|
||||
- `view_filters/BattalionNameFilter` - has Scala overload
|
||||
- `view_filters/BattleFilter` - has Scala overload
|
||||
|
||||
**Legacy Utilities (to be deprecated):**
|
||||
- `LegacyProvinceDistances`, `LegacyFactionUtils`, `LegacyHeroUtils`, etc.
|
||||
- Used by code that still needs proto GameState
|
||||
|
||||
**Persistence/Action System:**
|
||||
- `ActionResultTApplier`, `ActionResultProtoApplier` - apply results to proto state
|
||||
- `ActionWithResultingState` - caches both proto and Scala state
|
||||
|
||||
**Shardok Interface (gRPC boundary):**
|
||||
- `ShardokInterfaceGrpcClient`, `ShardokInterfaceProxy` - must use proto for C++ communication
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Phase 1-3: AI Layer ✅ COMPLETE
|
||||
|
||||
The entire AI decision-making layer is now protoless.
|
||||
|
||||
### Phase 4: View Filters ✅ COMPLETE
|
||||
|
||||
The view_filters package migration is complete:
|
||||
|
||||
**Completed:**
|
||||
- [x] `GameStateViewFilter` - added Scala GameState overload (uses Scala sub-filters)
|
||||
- [x] `ProvinceViewFilter` - already has Scala overloads for server-side views
|
||||
- [x] `HumanPlayerClientConnectionState` - updated to pass Scala GameState directly
|
||||
- [x] `HeroViewFilter` - added Scala overload
|
||||
- [x] `FactionViewFilter` - added Scala overload
|
||||
- [x] `Visibility` - added Scala overloads
|
||||
|
||||
**Still Using Proto:**
|
||||
- [x] `BattalionNameFilter` - has Scala overload
|
||||
- [x] `BattleFilter` - has Scala overload
|
||||
- [ ] `ActionResultFilter` - uses proto internally (boundary code)
|
||||
|
||||
**Strategy:**
|
||||
1. Add Scala GameState overloads to view filter methods
|
||||
2. Update callers to pass Scala GameState where available
|
||||
3. Eventually deprecate proto versions
|
||||
|
||||
### Phase 5: Legacy Utility Cleanup (IN PROGRESS)
|
||||
|
||||
Remove Legacy* utilities by migrating remaining callers:
|
||||
1. Identify callers of each Legacy* util
|
||||
2. Update callers to use protoless versions
|
||||
3. Delete Legacy* files when no longer needed
|
||||
|
||||
**Deleted (no production callers):**
|
||||
- [x] `LegacyProvinceDistances` - deleted (no callers)
|
||||
- [x] `LegacyBattalionSuitability` - deleted (no callers)
|
||||
- [x] `LegacyFoodConsumptionUtils` - deleted (no callers)
|
||||
- [x] `LegacyHandleRiotUtils` - deleted (no callers)
|
||||
|
||||
**Refactored to Thin Wrappers (delegating to protoless versions):**
|
||||
- [x] `LegacyRansomValidity` - already a thin wrapper delegating to `RansomValidity`
|
||||
- [x] `LegacyRecruitmentOdds` - refactored to delegate to `RecruitmentOdds`
|
||||
|
||||
**Parallel Implementations (proto mirrors protoless):**
|
||||
- [x] `FactionUtils` / `LegacyFactionUtils` - both have matching APIs; LegacyFactionUtils used by boundary code (24+ callers)
|
||||
- [x] `HeroUtils` / `LegacyHeroUtils` - both have matching APIs; LegacyHeroUtils used by boundary code (10 callers)
|
||||
- [x] `ProvinceUtils` / `LegacyProvinceUtils` - both have matching APIs; LegacyProvinceUtils used by boundary code (20 callers: availability factories, view filters)
|
||||
|
||||
**Parallel Implementations (awaiting migration of callers):**
|
||||
- [x] `BattalionUtils` / `LegacyBattalionUtils` - both have matching core methods; LegacyBattalionUtils used by boundary code (4 callers)
|
||||
- [x] `BattalionViewFilter` / `LegacyBattalionViewFilter` - protoless version exists; Legacy used by view filters, action appliers (3 callers)
|
||||
- [x] `BattalionTypeFinder` / `LegacyBattalionTypeFinder` - protoless version exists; Legacy used by validators (1 caller: RuntimeValidator)
|
||||
|
||||
### Recent Caller Migration
|
||||
|
||||
**CheckForFulfilledQuestsAction** - migrated to use protoless `BattalionTypeFinder`:
|
||||
- Changed `battalionTypes` parameter from proto `Vector[BattalionType]` to Scala `Vector[BattalionType]`
|
||||
- Updated callers (EngineImpl, EndVassalCommandsPhaseAction) to pass Scala types directly
|
||||
- Eliminated wasteful `BattalionTypeConverter.toProto()` conversions
|
||||
|
||||
**ExpandedUnaffiliatedHeroUtils** - added Scala overload:
|
||||
- New overload takes Scala `GameState` and `UnaffiliatedHeroT` directly
|
||||
- Added `UnaffiliatedHeroConverter.unaffiliatedHeroTypeToProto()` helper for efficient enum conversion
|
||||
- Proto overload retained for backward compatibility
|
||||
|
||||
**AvailablePleaseRecruitMeCommandFactory** - eliminated wasteful proto conversions:
|
||||
- Now uses `ExpandedUnaffiliatedHeroUtils.expandedUnaffiliatedHero(ScalaGameState, UnaffiliatedHeroT)` directly
|
||||
- Removed `GameStateConverter.toProto()` and `UnaffiliatedHeroConverter.toProto()` calls
|
||||
- Factory is now fully protoless internally (still returns proto types for API boundary)
|
||||
|
||||
**ProvinceViewFilter** - added Scala overload with faction filtering:
|
||||
- New `filteredProvinceView(province: ProvinceT, gs: ScalaGameState, factionId: FactionId)` overload
|
||||
- Uses protoless `FactionUtils.hasAlliance`, `Visibility.hasFullVisibility`, and `ProvinceUtils.incomingOthers`
|
||||
- Handles reconned provinces directly from Scala `FactionT.reconnedProvinces` (already Scala type)
|
||||
- Added helper methods: `fullProvinceInfoScala`, `maybeIncomingAttackersScala`, `unaffiliatedHeroInfoScala`
|
||||
- Events still converted to proto at the end (ProvinceView.knownEvents uses proto events)
|
||||
|
||||
**GameStateViewFilter** - eliminated GameStateConverter.toProto() call:
|
||||
- Scala overload now fully protoless internally
|
||||
- Uses the new ProvinceViewFilter Scala overload with faction filtering
|
||||
- Converts `battalionTypes` and `chronicleEntries` to proto only at output boundary
|
||||
|
||||
## Key Files
|
||||
|
||||
### Protoless Model Types
|
||||
|
||||
- `src/main/scala/net/eagle0/eagle/model/state/game_state/GameState.scala` - native Scala GameState
|
||||
- `src/main/scala/net/eagle0/eagle/model/state/province/ProvinceView.scala` - province view type
|
||||
- `src/main/scala/net/eagle0/eagle/model/state/faction/FactionView.scala` - faction view type
|
||||
- `src/main/scala/net/eagle0/eagle/model/state/hero/HeroView.scala` - hero view type
|
||||
|
||||
### Proto Converters
|
||||
|
||||
- `src/main/scala/net/eagle0/eagle/model/proto_converters/game_state/` - converts between proto and Scala types
|
||||
|
||||
## Notes
|
||||
|
||||
- The AI client code (`src/main/scala/net/eagle0/eagle/ai/`) is now fully protoless
|
||||
- Proto GameState is still needed at boundaries: persistence, gRPC to Shardok
|
||||
- `PerformUnaffiliatedHeroesAction` and the LLM pipeline use protoless `GameState`
|
||||
- `GameStateViewFilter` Scala overload is now fully protoless internally (converts to proto only at output)
|
||||
- `ProvinceViewFilter` has Scala overloads for all three modes: no filtering, faction filtering, and withdrawn-from view
|
||||
+104
-285
@@ -1,354 +1,173 @@
|
||||
module(name = "net_eagle0")
|
||||
|
||||
# Version constants
|
||||
SCALA_VERSION = "3.7.2"
|
||||
|
||||
NETTY_VERSION = "4.1.110.Final"
|
||||
|
||||
SCALAPB_VERSION = "1.0.0-alpha.1"
|
||||
|
||||
AWS_SDK_VERSION = "2.28.1"
|
||||
bazel_dep(name = "apple_support", repo_name = "build_bazel_apple_support", version = "1.21.1")
|
||||
|
||||
#
|
||||
# Core Build Tools
|
||||
# bazel-toolchain
|
||||
#
|
||||
|
||||
bazel_dep(name = "bazel_skylib", version = "1.9.0")
|
||||
bazel_dep(name = "rules_pkg", version = "1.2.0")
|
||||
|
||||
#
|
||||
# Language Support - Scala
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_scala", version = "7.1.1")
|
||||
|
||||
scala_config = use_extension(
|
||||
"@rules_scala//scala/extensions:config.bzl",
|
||||
"scala_config",
|
||||
)
|
||||
scala_config.settings(scala_version = SCALA_VERSION)
|
||||
|
||||
scala_deps = use_extension(
|
||||
"@rules_scala//scala/extensions:deps.bzl",
|
||||
"scala_deps",
|
||||
)
|
||||
scala_deps.scala()
|
||||
scala_deps.scalatest()
|
||||
scala_deps.scala_proto()
|
||||
|
||||
#
|
||||
# Language Support - C++
|
||||
#
|
||||
|
||||
bazel_dep(name = "toolchains_llvm", version = "1.6.0")
|
||||
bazel_dep(name = "toolchains_llvm", version = "1.2.0")
|
||||
|
||||
# Configure and register the toolchain.
|
||||
llvm = use_extension("@toolchains_llvm//toolchain/extensions:llvm.bzl", "llvm")
|
||||
|
||||
# Native toolchain (macOS -> macOS, Linux -> Linux)
|
||||
llvm.toolchain(
|
||||
name = "llvm_toolchain",
|
||||
llvm_version = "20.1.2",
|
||||
llvm_version = "19.1.0",
|
||||
)
|
||||
|
||||
# Cross-compilation toolchain (macOS -> Linux x86_64)
|
||||
# Uses the same LLVM distribution but with a Linux sysroot
|
||||
llvm.toolchain(
|
||||
name = "llvm_toolchain_linux",
|
||||
llvm_version = "20.1.2",
|
||||
use_repo(llvm, "llvm_toolchain")
|
||||
|
||||
# Set dev_dependency so we can turn this off for swift MacOS builds
|
||||
register_toolchains(
|
||||
"@llvm_toolchain//:all",
|
||||
dev_dependency = True,
|
||||
)
|
||||
|
||||
# Linux x86_64 sysroot for cross-compilation
|
||||
llvm.sysroot(
|
||||
name = "llvm_toolchain_linux",
|
||||
label = "@linux_sysroot//sysroot",
|
||||
targets = ["linux-x86_64"],
|
||||
)
|
||||
|
||||
# Cross-compilation toolchain (macOS -> Linux ARM64)
|
||||
llvm.toolchain(
|
||||
name = "llvm_toolchain_linux_arm64",
|
||||
llvm_version = "20.1.2",
|
||||
)
|
||||
|
||||
# Linux ARM64 sysroot for cross-compilation
|
||||
llvm.sysroot(
|
||||
name = "llvm_toolchain_linux_arm64",
|
||||
label = "@linux_sysroot_arm64//sysroot",
|
||||
targets = ["linux-aarch64"],
|
||||
)
|
||||
|
||||
use_repo(llvm, "llvm_toolchain", "llvm_toolchain_linux", "llvm_toolchain_linux_arm64")
|
||||
|
||||
# Download the Linux sysroots (Ubuntu 24.04 Noble for C++23 support)
|
||||
# Built by: .github/workflows/build_sysroot.yml
|
||||
# To rebuild: Run the "Build Linux Sysroot" workflow with a new version, then update sha256 and URL
|
||||
sysroot = use_repo_rule("@toolchains_llvm//toolchain:sysroot.bzl", "sysroot")
|
||||
|
||||
# x86_64 sysroot
|
||||
sysroot(
|
||||
name = "linux_sysroot",
|
||||
sha256 = "a06475004fe8003ae7ccb4fe1d5511feb9b27cce4a8826eb1dfd686ed83f3dba",
|
||||
urls = ["https://eagle0-sysroot.sfo3.digitaloceanspaces.com/v3/ubuntu_noble_amd64_sysroot.tar.xz"],
|
||||
)
|
||||
|
||||
# ARM64 sysroot
|
||||
sysroot(
|
||||
name = "linux_sysroot_arm64",
|
||||
sha256 = "87469137737e09bc73855007dab835477eb10a7b3ce3f725f93f64e25747f3f9",
|
||||
urls = ["https://eagle0-sysroot.sfo3.digitaloceanspaces.com/v4/ubuntu_noble_arm64_sysroot.tar.xz"],
|
||||
)
|
||||
|
||||
#
|
||||
# Language Support - Go
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_go", version = "0.59.0", repo_name = "io_bazel_rules_go")
|
||||
bazel_dep(name = "gazelle", version = "0.47.0", repo_name = "bazel_gazelle")
|
||||
bazel_dep(name = "rules_pkg", version = "1.0.1")
|
||||
bazel_dep(name = "bazel_skylib", version = "1.7.1")
|
||||
bazel_dep(name = "protobuf", repo_name = "com_google_protobuf", version = "29.2")
|
||||
bazel_dep(name = "grpc", version = "1.71.0")
|
||||
bazel_dep(name = "grpc-java", version = "1.71.0")
|
||||
bazel_dep(name = "googletest", version = "1.15.2")
|
||||
bazel_dep(name = "rules_go", repo_name = "io_bazel_rules_go", version = "0.50.1")
|
||||
bazel_dep(name = "gazelle", repo_name = "bazel_gazelle", version = "0.40.0")
|
||||
|
||||
go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk")
|
||||
|
||||
go_sdk.download(version = "1.23.3")
|
||||
|
||||
go_deps = use_extension("@bazel_gazelle//:extensions.bzl", "go_deps")
|
||||
|
||||
go_deps.from_file(go_mod = "//:go.mod")
|
||||
|
||||
use_repo(
|
||||
go_deps,
|
||||
"com_github_aws_aws_sdk_go_v2",
|
||||
"com_github_aws_aws_sdk_go_v2_config",
|
||||
"com_github_aws_aws_sdk_go_v2_credentials",
|
||||
"com_github_aws_aws_sdk_go_v2_service_s3",
|
||||
"com_github_golang_jwt_jwt_v5",
|
||||
"com_github_google_uuid",
|
||||
"org_golang_google_grpc",
|
||||
"org_golang_google_protobuf",
|
||||
"org_golang_x_text",
|
||||
"com_github_google_go_cmp",
|
||||
)
|
||||
|
||||
#go_sdk.nogo(
|
||||
# nogo = "//:my_nogo",
|
||||
#)
|
||||
|
||||
#
|
||||
# Platform Support - Apple/iOS
|
||||
# rules_jvm_external
|
||||
#
|
||||
|
||||
bazel_dep(name = "apple_support", version = "1.21.1", repo_name = "build_bazel_apple_support")
|
||||
bazel_dep(name = "rules_apple", version = "4.3.3", repo_name = "build_bazel_rules_apple")
|
||||
bazel_dep(name = "rules_swift", version = "2.4.0", repo_name = "build_bazel_rules_swift")
|
||||
scala_version = "2.13.14"
|
||||
|
||||
# Register Apple CC toolchain for Objective-C compilation
|
||||
apple_cc_configure = use_extension(
|
||||
"@build_bazel_apple_support//crosstool:setup.bzl",
|
||||
"apple_cc_configure_extension",
|
||||
bazel_dep(
|
||||
name = "rules_jvm_external",
|
||||
version = "6.3",
|
||||
)
|
||||
use_repo(apple_cc_configure, "local_config_apple_cc")
|
||||
|
||||
#
|
||||
# Protocol Buffers & RPC
|
||||
#
|
||||
|
||||
bazel_dep(name = "protobuf", version = "29.2", repo_name = "com_google_protobuf")
|
||||
bazel_dep(name = "grpc", version = "1.71.0")
|
||||
bazel_dep(name = "grpc-java", version = "1.71.0")
|
||||
bazel_dep(name = "flatbuffers", version = "25.9.23")
|
||||
|
||||
#
|
||||
# Testing
|
||||
#
|
||||
|
||||
bazel_dep(name = "googletest", version = "1.17.0")
|
||||
|
||||
#
|
||||
# Container Images (OCI)
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_oci", version = "2.2.7")
|
||||
bazel_dep(name = "aspect_bazel_lib", version = "2.22.4")
|
||||
|
||||
oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
|
||||
|
||||
# Base image for Eagle (Java 17 JDK - includes jcmd for JFR dumps)
|
||||
oci.pull(
|
||||
name = "eclipse_temurin_17",
|
||||
image = "docker.io/library/eclipse-temurin",
|
||||
platforms = ["linux/amd64"],
|
||||
tag = "17-jdk",
|
||||
)
|
||||
|
||||
# Base image for Shardok (Ubuntu 24.04 for C++ runtime)
|
||||
oci.pull(
|
||||
name = "ubuntu_24_04",
|
||||
image = "docker.io/library/ubuntu",
|
||||
platforms = [
|
||||
"linux/amd64",
|
||||
"linux/arm64/v8",
|
||||
],
|
||||
tag = "24.04",
|
||||
)
|
||||
|
||||
# Base image for Admin Server (Alpine for lightweight Go binary)
|
||||
oci.pull(
|
||||
name = "alpine_linux",
|
||||
image = "docker.io/library/alpine",
|
||||
platforms = ["linux/amd64"],
|
||||
tag = "3.21",
|
||||
)
|
||||
use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_17", "eclipse_temurin_17_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64", "ubuntu_24_04_linux_arm64_v8")
|
||||
|
||||
#
|
||||
# Java/Scala Dependencies
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_jvm_external", version = "6.9")
|
||||
|
||||
maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")
|
||||
|
||||
maven.install(
|
||||
artifacts = [
|
||||
# Netty
|
||||
"io.netty:netty-codec:%s" % NETTY_VERSION,
|
||||
"io.netty:netty-codec-http:%s" % NETTY_VERSION,
|
||||
"io.netty:netty-codec-socks:%s" % NETTY_VERSION,
|
||||
"io.netty:netty-codec-http2:%s" % NETTY_VERSION,
|
||||
"io.netty:netty-handler:%s" % NETTY_VERSION,
|
||||
"io.netty:netty-buffer:%s" % NETTY_VERSION,
|
||||
"io.netty:netty-transport:%s" % NETTY_VERSION,
|
||||
"io.netty:netty-resolver:%s" % NETTY_VERSION,
|
||||
"io.netty:netty-common:%s" % NETTY_VERSION,
|
||||
"io.netty:netty-handler-proxy:%s" % NETTY_VERSION,
|
||||
|
||||
# ScalaPB
|
||||
"com.thesamet.scalapb:lenses_3:%s" % SCALAPB_VERSION,
|
||||
"com.thesamet.scalapb:scalapb-json4s_3:%s" % SCALAPB_VERSION,
|
||||
"com.thesamet.scalapb:scalapb-runtime_3:%s" % SCALAPB_VERSION,
|
||||
"com.thesamet.scalapb:scalapb-runtime-grpc_3:%s" % SCALAPB_VERSION,
|
||||
"com.thesamet.scalapb:compilerplugin_3:%s" % SCALAPB_VERSION,
|
||||
"com.thesamet.scalapb:protoc-bridge_3:0.9.9",
|
||||
|
||||
# JSON
|
||||
"org.json4s:json4s-ast_3:4.1.0-M8",
|
||||
"org.json4s:json4s-core_3:4.1.0-M8",
|
||||
"org.json4s:json4s-native_3:4.1.0-M8",
|
||||
|
||||
# Testing
|
||||
"org.scalamock:scalamock_3:7.4.1",
|
||||
|
||||
# AWS SDK
|
||||
"software.amazon.awssdk:s3-transfer-manager:%s" % AWS_SDK_VERSION,
|
||||
"software.amazon.awssdk:s3:%s" % AWS_SDK_VERSION,
|
||||
"software.amazon.awssdk:regions:%s" % AWS_SDK_VERSION,
|
||||
"software.amazon.awssdk:aws-core:%s" % AWS_SDK_VERSION,
|
||||
"software.amazon.awssdk:sdk-core:%s" % AWS_SDK_VERSION,
|
||||
"software.amazon.awssdk:utils:%s" % AWS_SDK_VERSION,
|
||||
"software.amazon.awssdk:http-client-spi:%s" % AWS_SDK_VERSION,
|
||||
|
||||
# AWS Lambda
|
||||
"com.amazonaws:aws-lambda-java-core:1.2.3",
|
||||
"com.amazonaws:aws-lambda-java-events:3.13.0",
|
||||
|
||||
# Logging
|
||||
"org.scala-lang:scala-library:%s" % scala_version,
|
||||
"io.netty:netty-codec:4.1.110.Final",
|
||||
"io.netty:netty-codec-http:4.1.110.Final",
|
||||
"io.netty:netty-codec-socks:4.1.110.Final",
|
||||
"io.netty:netty-codec-http2:4.1.110.Final",
|
||||
"io.netty:netty-handler:4.1.110.Final",
|
||||
"io.netty:netty-buffer:4.1.110.Final",
|
||||
"io.netty:netty-transport:4.1.110.Final",
|
||||
"io.netty:netty-resolver:4.1.110.Final",
|
||||
"io.netty:netty-common:4.1.110.Final",
|
||||
"io.netty:netty-handler-proxy:4.1.110.Final",
|
||||
"com.thesamet.scalapb:lenses_2.13:1.0.0-alpha.1",
|
||||
"com.thesamet.scalapb:scalapb-json4s_2.13:1.0.0-alpha.1",
|
||||
"com.thesamet.scalapb:scalapb-runtime_2.13:1.0.0-alpha.1",
|
||||
"com.thesamet.scalapb:scalapb-runtime-grpc_2.13:1.0.0-alpha.1",
|
||||
"com.thesamet.scalapb:compilerplugin_2.13:1.0.0-alpha.1",
|
||||
"com.thesamet.scalapb:protoc-bridge_2.13:0.9.8",
|
||||
"org.json4s:json4s-ast_2.13:4.0.7",
|
||||
"org.json4s:json4s-core_2.13:4.0.7",
|
||||
"org.json4s:json4s-native_2.13:4.0.7",
|
||||
"org.scalamock:scalamock_2.13:6.0.0",
|
||||
"software.amazon.awssdk:s3-transfer-manager:2.28.1",
|
||||
"software.amazon.awssdk:s3:2.28.1",
|
||||
"software.amazon.awssdk:regions:2.28.1",
|
||||
"software.amazon.awssdk:aws-core:2.28.1",
|
||||
"software.amazon.awssdk:sdk-core:2.28.1",
|
||||
"org.slf4j:slf4j-api:2.0.16",
|
||||
"org.slf4j:slf4j-simple:2.0.16",
|
||||
|
||||
# Other
|
||||
#"software.amazon.awssdk:sns:2.28.1",
|
||||
"software.amazon.awssdk:utils:2.28.1",
|
||||
"software.amazon.awssdk:http-client-spi:2.28.1",
|
||||
"org.reactivestreams:reactive-streams:1.0.4",
|
||||
"com.amazonaws:aws-lambda-java-core:1.2.3",
|
||||
"com.amazonaws:aws-lambda-java-events:3.13.0",
|
||||
"javax.xml.bind:jaxb-api:2.3.1",
|
||||
|
||||
# OkHttp (for SSE with read timeout support, OAuth HTTP calls)
|
||||
"com.squareup.okhttp3:okhttp:4.12.0",
|
||||
"com.squareup.okhttp3:okhttp-sse:4.12.0",
|
||||
|
||||
# JWT (for OAuth token handling)
|
||||
"com.nimbusds:nimbus-jose-jwt:9.37.3",
|
||||
|
||||
# Error tracking
|
||||
"io.sentry:sentry:7.19.0",
|
||||
],
|
||||
duplicate_version_warning = "error",
|
||||
fail_if_repin_required = True,
|
||||
lock_file = "//:maven_install.json",
|
||||
lock_file = "//:maven_install.json", #
|
||||
repositories = [
|
||||
"https://repo1.maven.org/maven2",
|
||||
],
|
||||
)
|
||||
|
||||
use_repo(maven, "maven", "unpinned_maven")
|
||||
|
||||
#
|
||||
# External Libraries
|
||||
# rules_apple
|
||||
#
|
||||
|
||||
bazel_dep(
|
||||
name = "rules_apple",
|
||||
repo_name = "build_bazel_rules_apple",
|
||||
version = "3.16.1",
|
||||
)
|
||||
bazel_dep(
|
||||
name = "rules_swift",
|
||||
repo_name = "build_bazel_rules_swift",
|
||||
version = "2.3.1",
|
||||
)
|
||||
|
||||
#
|
||||
# Unbazelified imports
|
||||
#
|
||||
http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
|
||||
http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file")
|
||||
|
||||
# GTL (for parallel_hashmap)
|
||||
GTL_VERSION = "1.2.0"
|
||||
#
|
||||
# flatbuffers
|
||||
#
|
||||
bazel_dep(name = "flatbuffers", version = "25.2.10")
|
||||
|
||||
GTL_SHA = "1969c45dd76eac0dd87e9e2b65cffe358617f4fe1bcd203f72f427742537913a"
|
||||
#
|
||||
# gtl (for parallel_hashmap)
|
||||
#
|
||||
|
||||
gtl_version = "1.2.0"
|
||||
|
||||
gtl_sha = "1969c45dd76eac0dd87e9e2b65cffe358617f4fe1bcd203f72f427742537913a"
|
||||
|
||||
http_archive(
|
||||
name = "gtl",
|
||||
build_file = "@//external:BUILD.gtl",
|
||||
sha256 = GTL_SHA,
|
||||
strip_prefix = "gtl-%s" % GTL_VERSION,
|
||||
url = "https://github.com/greg7mdp/gtl/archive/refs/tags/v%s.zip" % GTL_VERSION,
|
||||
sha256 = gtl_sha,
|
||||
strip_prefix = "gtl-%s" % gtl_version,
|
||||
url = "https://github.com/greg7mdp/gtl/archive/refs/tags/v%s.zip" % gtl_version,
|
||||
)
|
||||
|
||||
# Unity GoDice Plugin
|
||||
UNITY_GODICE_COMMIT = "18d6823991592e4d45fcc0f22692db849dea9063"
|
||||
#
|
||||
# Plugins for the native code for interacting with GoDice
|
||||
#
|
||||
unity_godice_commit = "18d6823991592e4d45fcc0f22692db849dea9063"
|
||||
|
||||
UNITY_GODICE_SHA = "04e6ae4155965aab3372592e04061eba1256bb6ea7ccffd0d83f27574e5b3349"
|
||||
unity_godice_sha = "04e6ae4155965aab3372592e04061eba1256bb6ea7ccffd0d83f27574e5b3349"
|
||||
|
||||
http_archive(
|
||||
name = "net_eagle0_unity_godice",
|
||||
sha256 = UNITY_GODICE_SHA,
|
||||
strip_prefix = "godice-framework-%s" % UNITY_GODICE_COMMIT,
|
||||
sha256 = unity_godice_sha,
|
||||
strip_prefix = "godice-framework-%s" % unity_godice_commit,
|
||||
urls = [
|
||||
"https://github.com/nolen777/godice-framework/archive/%s.zip" % UNITY_GODICE_COMMIT,
|
||||
"https://github.com/nolen777/godice-framework/archive/%s.zip" % unity_godice_commit,
|
||||
],
|
||||
)
|
||||
|
||||
# Sparkle framework for macOS auto-updates
|
||||
SPARKLE_VERSION = "2.6.4"
|
||||
|
||||
http_archive(
|
||||
name = "sparkle",
|
||||
build_file = "@//external:BUILD.sparkle",
|
||||
sha256 = "50612a06038abc931f16011d7903b8326a362c1074dabccb718404ce8e585f0b",
|
||||
strip_prefix = "",
|
||||
url = "https://github.com/sparkle-project/Sparkle/releases/download/%s/Sparkle-%s.tar.xz" % (SPARKLE_VERSION, SPARKLE_VERSION),
|
||||
)
|
||||
|
||||
# Busybox static binary for Docker health checks (provides nc, wget, etc.)
|
||||
# Primary: DigitalOcean Spaces (public, reliable)
|
||||
# Fallback: busybox.net (can be unreliable/slow)
|
||||
http_file(
|
||||
name = "busybox_x86_64",
|
||||
sha256 = "6e123e7f3202a8c1e9b1f94d8941580a25135382b99e8d3e34fb858bba311348",
|
||||
urls = [
|
||||
"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/busybox/busybox-1.35.0-x86_64-linux-musl",
|
||||
"https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox",
|
||||
],
|
||||
downloaded_file_path = "busybox",
|
||||
executable = True,
|
||||
)
|
||||
|
||||
http_file(
|
||||
name = "busybox_aarch64",
|
||||
sha256 = "141adb1b625a6f44c4b114f76b4387b4ea4f7ab802b88eb40e0d2f6adcccb1c3",
|
||||
urls = [
|
||||
# TODO: Upload aarch64 binary to GitHub release when needed
|
||||
"https://busybox.net/downloads/binaries/1.35.0-aarch64-linux-musl/busybox",
|
||||
],
|
||||
downloaded_file_path = "busybox",
|
||||
executable = True,
|
||||
)
|
||||
|
||||
#
|
||||
# Toolchain Registration
|
||||
#
|
||||
|
||||
register_toolchains(
|
||||
"//tools:unused_dependency_checker_error_and_opts_toolchain",
|
||||
"@rules_scala//testing:scalatest_toolchain",
|
||||
)
|
||||
|
||||
# Set dev_dependency so we can turn this off for swift MacOS builds
|
||||
register_toolchains(
|
||||
"@llvm_toolchain//:all",
|
||||
"@llvm_toolchain_linux//:all",
|
||||
"@llvm_toolchain_linux_arm64//:all",
|
||||
dev_dependency = True,
|
||||
)
|
||||
|
||||
Generated
+128
-3973
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,51 @@
|
||||
# This file marks the root of the Bazel workspace.
|
||||
# See MODULE.bazel for external dependencies and setup.
|
||||
workspace(name = "net_eagle0")
|
||||
|
||||
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
|
||||
|
||||
#
|
||||
# Scala support
|
||||
#
|
||||
|
||||
scala_version = "2.13.14"
|
||||
|
||||
#rules_scala_version = "6.6.0"
|
||||
|
||||
#rules_scala_sha = "e734eef95cf26c0171566bdc24d83bd82bdaf8ca7873bec6ce9b0d524bdaf05d"
|
||||
|
||||
#http_archive(
|
||||
# name = "io_bazel_rules_scala",
|
||||
# sha256 = rules_scala_sha,
|
||||
# strip_prefix = "rules_scala-%s" % rules_scala_version,
|
||||
# url = "https://github.com/bazelbuild/rules_scala/releases/download/v%s/rules_scala-v%s.tar.gz" % (rules_scala_version, rules_scala_version),
|
||||
#)
|
||||
|
||||
# Using a commit from master to get 2.13.14 support. Restore the commented-out lines above with a new
|
||||
# release version when one is cut.
|
||||
rules_scala_commit = "e53a43bf48f10a5906b3e91c21798281cec1b334"
|
||||
|
||||
rules_scala_sha = "b4fd903724d084d9d9f45e17fc22391bda745bf0574f8934d38a9c1c2fc18834"
|
||||
|
||||
http_archive(
|
||||
name = "io_bazel_rules_scala",
|
||||
sha256 = rules_scala_sha,
|
||||
strip_prefix = "rules_scala-%s" % rules_scala_commit,
|
||||
url = "https://github.com/bazelbuild/rules_scala/archive/%s.zip" % rules_scala_commit,
|
||||
)
|
||||
|
||||
load("@io_bazel_rules_scala//:scala_config.bzl", "scala_config")
|
||||
|
||||
scala_config(scala_version = scala_version)
|
||||
|
||||
load("//tools:toolchains.bzl", "scala_register_toolchains")
|
||||
|
||||
scala_register_toolchains()
|
||||
|
||||
load("@io_bazel_rules_scala//scala:scala.bzl", "scala_repositories")
|
||||
|
||||
scala_repositories()
|
||||
|
||||
load("@io_bazel_rules_scala//testing:scalatest.bzl", "scalatest_repositories", "scalatest_toolchain")
|
||||
|
||||
scalatest_repositories()
|
||||
|
||||
scalatest_toolchain()
|
||||
|
||||
-347
@@ -1,347 +0,0 @@
|
||||
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load", "oci_push")
|
||||
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
|
||||
|
||||
#
|
||||
# Shared utilities layer (busybox for nc, wget, etc.)
|
||||
#
|
||||
|
||||
pkg_tar(
|
||||
name = "busybox_layer",
|
||||
srcs = ["@busybox_x86_64//file"],
|
||||
package_dir = "/usr/local/bin",
|
||||
remap_paths = {
|
||||
"file/busybox": "busybox",
|
||||
},
|
||||
symlinks = {
|
||||
"/usr/local/bin/nc": "busybox",
|
||||
},
|
||||
)
|
||||
|
||||
pkg_tar(
|
||||
name = "busybox_layer_arm64",
|
||||
srcs = ["@busybox_aarch64//file"],
|
||||
package_dir = "/usr/local/bin",
|
||||
remap_paths = {
|
||||
"file/busybox": "busybox",
|
||||
},
|
||||
symlinks = {
|
||||
"/usr/local/bin/nc": "busybox",
|
||||
},
|
||||
)
|
||||
|
||||
#
|
||||
# Eagle Server Docker Image
|
||||
#
|
||||
# Build: bazel build //ci:eagle_server_image
|
||||
# Load: bazel run //ci:eagle_server_load
|
||||
# Push: bazel run //ci:eagle_server_push
|
||||
#
|
||||
|
||||
# Package the deploy JAR
|
||||
pkg_tar(
|
||||
name = "eagle_server_jar_layer",
|
||||
srcs = ["//src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar"],
|
||||
package_dir = "/app",
|
||||
)
|
||||
|
||||
# Package the game resources needed at runtime
|
||||
pkg_tar(
|
||||
name = "eagle_resources_layer",
|
||||
srcs = [
|
||||
"//src/main/resources/net/eagle0/eagle:beasts",
|
||||
"//src/main/resources/net/eagle0/eagle:game_parameters",
|
||||
"//src/main/resources/net/eagle0/eagle:headshots",
|
||||
"//src/main/resources/net/eagle0/eagle:heroes",
|
||||
"//src/main/resources/net/eagle0/eagle:province_map",
|
||||
"//src/main/resources/net/eagle0/eagle:settings",
|
||||
],
|
||||
package_dir = "/app/resources",
|
||||
)
|
||||
|
||||
oci_image(
|
||||
name = "eagle_server_image",
|
||||
base = "@eclipse_temurin_17_linux_amd64",
|
||||
entrypoint = [
|
||||
"java",
|
||||
"-Xmx2g",
|
||||
"-XX:+UseG1GC",
|
||||
# JFR profiling support
|
||||
"-XX:+UnlockDiagnosticVMOptions",
|
||||
"-XX:+DebugNonSafepoints", # Required for JFR to see through inlined methods
|
||||
"-XX:FlightRecorderOptions=stackdepth=256",
|
||||
"-jar",
|
||||
"/app/eagle_server_deploy.jar",
|
||||
],
|
||||
env = {
|
||||
"JAVA_OPTS": "-Xmx2g -XX:+UseG1GC",
|
||||
},
|
||||
exposed_ports = ["40032/tcp"],
|
||||
tars = [
|
||||
":busybox_layer",
|
||||
":eagle_server_jar_layer",
|
||||
":eagle_resources_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
)
|
||||
|
||||
# Load into Docker locally: bazel run //ci:eagle_server_load
|
||||
oci_load(
|
||||
name = "eagle_server_load",
|
||||
image = ":eagle_server_image",
|
||||
repo_tags = ["eagle0/eagle-server:latest"],
|
||||
)
|
||||
|
||||
# Push to DigitalOcean Container Registry
|
||||
# Note: No remote_tags here - DigitalOcean converts OCI to Docker format,
|
||||
# changing the digest and breaking oci_push's tag-by-digest logic.
|
||||
# Tagging is handled in the CI workflow using crane copy/tag.
|
||||
oci_push(
|
||||
name = "eagle_server_push",
|
||||
image = ":eagle_server_image",
|
||||
repository = "registry.digitalocean.com/eagle0/eagle-server",
|
||||
)
|
||||
|
||||
#
|
||||
# Shardok Server Docker Image
|
||||
#
|
||||
# Build: bazel build //ci:shardok_server_image
|
||||
# Load: bazel run //ci:shardok_server_load
|
||||
# Push: bazel run //ci:shardok_server_push
|
||||
#
|
||||
|
||||
# Package the Shardok binary
|
||||
pkg_tar(
|
||||
name = "shardok_binary_layer",
|
||||
srcs = ["//src/main/cpp/net/eagle0/shardok:shardok-server"],
|
||||
package_dir = "/app",
|
||||
)
|
||||
|
||||
# Package the Shardok resources (battalion types, settings)
|
||||
pkg_tar(
|
||||
name = "shardok_resources_layer",
|
||||
srcs = [
|
||||
"//src/main/resources/net/eagle0/shardok:battalion_types",
|
||||
"//src/main/resources/net/eagle0/shardok:settings",
|
||||
],
|
||||
package_dir = "/app/resources",
|
||||
)
|
||||
|
||||
# Package the converted maps
|
||||
pkg_tar(
|
||||
name = "shardok_maps_layer",
|
||||
srcs = ["//src/main/resources/net/eagle0/shardok/maps"],
|
||||
package_dir = "/app/resources/maps",
|
||||
)
|
||||
|
||||
oci_image(
|
||||
name = "shardok_server_image",
|
||||
base = "@ubuntu_24_04_linux_amd64",
|
||||
entrypoint = ["/app/shardok-server"],
|
||||
exposed_ports = [
|
||||
"40042/tcp",
|
||||
"40052/tcp",
|
||||
],
|
||||
tars = [
|
||||
":busybox_layer",
|
||||
":shardok_binary_layer",
|
||||
":shardok_resources_layer",
|
||||
":shardok_maps_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
)
|
||||
|
||||
# Load into Docker locally: bazel run //ci:shardok_server_load
|
||||
oci_load(
|
||||
name = "shardok_server_load",
|
||||
image = ":shardok_server_image",
|
||||
repo_tags = ["eagle0/shardok-server:latest"],
|
||||
)
|
||||
|
||||
# Push to DigitalOcean Container Registry
|
||||
# Note: No remote_tags here - DigitalOcean converts OCI to Docker format,
|
||||
# changing the digest and breaking oci_push's tag-by-digest logic.
|
||||
# Tagging is handled in the CI workflow using crane copy/tag.
|
||||
oci_push(
|
||||
name = "shardok_server_push",
|
||||
image = ":shardok_server_image",
|
||||
repository = "registry.digitalocean.com/eagle0/shardok-server",
|
||||
)
|
||||
|
||||
#
|
||||
# Shardok Server ARM64 Docker Image (for Hetzner on-demand compute)
|
||||
#
|
||||
# Build: bazel build //ci:shardok_server_image_arm64 --platforms=//:linux_arm64 --extra_toolchains=@llvm_toolchain_linux_arm64//:all
|
||||
# Load: bazel run //ci:shardok_server_load_arm64
|
||||
# Push: bazel run //ci:shardok_server_push_arm64
|
||||
#
|
||||
|
||||
# Package the Shardok binary (ARM64 version - must be built with --platforms=//:linux_arm64)
|
||||
pkg_tar(
|
||||
name = "shardok_binary_layer_arm64",
|
||||
srcs = ["//src/main/cpp/net/eagle0/shardok:shardok-server"],
|
||||
package_dir = "/app",
|
||||
)
|
||||
|
||||
oci_image(
|
||||
name = "shardok_server_image_arm64",
|
||||
base = "@ubuntu_24_04_linux_arm64_v8",
|
||||
entrypoint = ["/app/shardok-server"],
|
||||
exposed_ports = [
|
||||
"40042/tcp",
|
||||
"40052/tcp",
|
||||
],
|
||||
tars = [
|
||||
# Note: busybox_layer_arm64 omitted - busybox.net has SSL issues
|
||||
# Health checks can use the shardok-server binary itself or be added later
|
||||
":shardok_binary_layer_arm64",
|
||||
":shardok_resources_layer",
|
||||
":shardok_maps_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
)
|
||||
|
||||
# Load into Docker locally (ARM64): bazel run //ci:shardok_server_load_arm64
|
||||
oci_load(
|
||||
name = "shardok_server_load_arm64",
|
||||
image = ":shardok_server_image_arm64",
|
||||
repo_tags = ["eagle0/shardok-server:latest-arm64"],
|
||||
)
|
||||
|
||||
# Push to DigitalOcean Container Registry (for Hetzner deployment)
|
||||
# Uses same repository as x86 but with arm64- tag prefix
|
||||
oci_push(
|
||||
name = "shardok_server_push_arm64",
|
||||
image = ":shardok_server_image_arm64",
|
||||
repository = "registry.digitalocean.com/eagle0/shardok-server",
|
||||
)
|
||||
|
||||
#
|
||||
# Admin Server Docker Image (Go)
|
||||
#
|
||||
# Build: bazel build //ci:admin_server_image
|
||||
# Load: bazel run //ci:admin_server_load
|
||||
# Push: bazel run //ci:admin_server_push
|
||||
#
|
||||
|
||||
# Package the Go admin binary (explicit Linux x86_64 target)
|
||||
pkg_tar(
|
||||
name = "admin_binary_layer",
|
||||
srcs = ["//src/main/go/net/eagle0/admin_server:admin_server_linux_amd64"],
|
||||
package_dir = "/app",
|
||||
)
|
||||
|
||||
oci_image(
|
||||
name = "admin_server_image",
|
||||
base = "@alpine_linux_linux_amd64",
|
||||
entrypoint = ["/app/admin_server_linux_amd64"],
|
||||
exposed_ports = ["8080/tcp"],
|
||||
tars = [
|
||||
":busybox_layer",
|
||||
":admin_binary_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
)
|
||||
|
||||
# Load into Docker locally: bazel run //ci:admin_server_load
|
||||
oci_load(
|
||||
name = "admin_server_load",
|
||||
image = ":admin_server_image",
|
||||
repo_tags = ["eagle0/admin-server:latest"],
|
||||
)
|
||||
|
||||
# Push to DigitalOcean Container Registry
|
||||
oci_push(
|
||||
name = "admin_server_push",
|
||||
image = ":admin_server_image",
|
||||
repository = "registry.digitalocean.com/eagle0/admin-server",
|
||||
)
|
||||
|
||||
#
|
||||
# JFR Sidecar Docker Image (Go + JDK for jcmd)
|
||||
#
|
||||
# This sidecar runs with shared PID namespace to access the Eagle JVM.
|
||||
# Build: bazel build //ci:jfr_sidecar_image
|
||||
# Load: bazel run //ci:jfr_sidecar_load
|
||||
# Push: bazel run //ci:jfr_sidecar_push
|
||||
#
|
||||
|
||||
# Package the Go JFR server binary
|
||||
pkg_tar(
|
||||
name = "jfr_sidecar_binary_layer",
|
||||
srcs = ["//src/main/go/net/eagle0/jfr_server:jfr_server_linux_amd64"],
|
||||
package_dir = "/app",
|
||||
)
|
||||
|
||||
oci_image(
|
||||
name = "jfr_sidecar_image",
|
||||
# Use JDK base image - we need jcmd to dump JFR recordings
|
||||
base = "@eclipse_temurin_17_linux_amd64",
|
||||
entrypoint = ["/app/jfr_server_linux_amd64"],
|
||||
exposed_ports = ["8081/tcp"],
|
||||
tars = [
|
||||
":jfr_sidecar_binary_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
)
|
||||
|
||||
# Load into Docker locally: bazel run //ci:jfr_sidecar_load
|
||||
oci_load(
|
||||
name = "jfr_sidecar_load",
|
||||
image = ":jfr_sidecar_image",
|
||||
repo_tags = ["eagle0/jfr-sidecar:latest"],
|
||||
)
|
||||
|
||||
# Push to DigitalOcean Container Registry
|
||||
oci_push(
|
||||
name = "jfr_sidecar_push",
|
||||
image = ":jfr_sidecar_image",
|
||||
repository = "registry.digitalocean.com/eagle0/jfr-sidecar",
|
||||
)
|
||||
|
||||
#
|
||||
# Auth Server Docker Image (Go)
|
||||
#
|
||||
# This is the external OAuth service that handles OAuth flows and JWT creation.
|
||||
# Build: bazel build //ci:auth_server_image
|
||||
# Load: bazel run //ci:auth_server_load
|
||||
# Push: bazel run //ci:auth_server_push
|
||||
#
|
||||
|
||||
# Package the Go auth binary (explicit Linux x86_64 target)
|
||||
pkg_tar(
|
||||
name = "auth_binary_layer",
|
||||
srcs = [
|
||||
"//src/main/go/net/eagle0/authcli:authcli_linux_amd64",
|
||||
"//src/main/go/net/eagle0/authservice:authservice_linux_amd64",
|
||||
],
|
||||
package_dir = "/app",
|
||||
)
|
||||
|
||||
oci_image(
|
||||
name = "auth_server_image",
|
||||
base = "@alpine_linux_linux_amd64",
|
||||
entrypoint = ["/app/authservice_linux_amd64"],
|
||||
exposed_ports = [
|
||||
"40033/tcp", # gRPC
|
||||
"8080/tcp", # HTTP OAuth callback
|
||||
],
|
||||
tars = [
|
||||
":busybox_layer",
|
||||
":auth_binary_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
)
|
||||
|
||||
# Load into Docker locally: bazel run //ci:auth_server_load
|
||||
oci_load(
|
||||
name = "auth_server_load",
|
||||
image = ":auth_server_image",
|
||||
repo_tags = ["eagle0/auth-server:latest"],
|
||||
)
|
||||
|
||||
# Push to DigitalOcean Container Registry
|
||||
oci_push(
|
||||
name = "auth_server_push",
|
||||
image = ":auth_server_image",
|
||||
repository = "registry.digitalocean.com/eagle0/auth-server",
|
||||
)
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
. ./ci/unity_version.sh
|
||||
|
||||
WORKSPACE=$(pwd)
|
||||
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
|
||||
BUILD_DIR=$1
|
||||
LOG_PATH=$2
|
||||
|
||||
echo "Building Mac in $BUILD_DIR"
|
||||
|
||||
echo "Cleaning up $BUILD_DIR"
|
||||
/bin/rm -rf "$BUILD_DIR"
|
||||
/bin/mkdir -p "$BUILD_DIR"
|
||||
|
||||
${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
|
||||
-nographics \
|
||||
-batchmode \
|
||||
-quit \
|
||||
-buildOSXUniversalPlayer "$BUILD_DIR/eagle0.app" \
|
||||
-logFile "$LOG_PATH" \
|
||||
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
COMMIT=$(/usr/bin/git rev-parse --short HEAD)
|
||||
|
||||
/bin/echo "build protos"
|
||||
./scripts/build_protos.sh
|
||||
|
||||
/bin/echo "build Mac plugin"
|
||||
./scripts/build_mac_plugin.sh
|
||||
|
||||
git log -3
|
||||
|
||||
/bin/echo "build Mac"
|
||||
LOG_PATH="/tmp/eagle0/editor_mac.log"
|
||||
BUILD_DIR=$1
|
||||
|
||||
./ci/github_actions/build_mac.sh "$BUILD_DIR" "$LOG_PATH"
|
||||
@@ -1,20 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -uxo pipefail
|
||||
set -euxo pipefail
|
||||
|
||||
/bin/echo "persist Library/"
|
||||
|
||||
# rsync may exit with code 23 ("partial transfer due to error") if Unity's
|
||||
# temporary files vanish during the copy. This is acceptable for a cache.
|
||||
/usr/bin/rsync -rtlDvq src/main/csharp/net/eagle0/clients/unity/eagle0/Library/ /tmp/eagle0/Library/
|
||||
rsync_exit=$?
|
||||
|
||||
if [ $rsync_exit -eq 0 ]; then
|
||||
exit 0
|
||||
elif [ $rsync_exit -eq 23 ]; then
|
||||
echo "Warning: rsync exited with 23 (some files vanished during copy). This is expected for Unity temp files."
|
||||
exit 0
|
||||
else
|
||||
echo "Error: rsync failed with exit code $rsync_exit"
|
||||
exit $rsync_exit
|
||||
fi
|
||||
/usr/bin/rsync -rtlDvq src/main/csharp/net/eagle0/clients/unity/eagle0/Library/ /tmp/eagle0/Library/
|
||||
@@ -1,18 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Allow JIT compilation (required for Mono/IL2CPP) -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<!-- Allow unsigned executable memory (required for Unity) -->
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<!-- Disable library validation (required for plugins) -->
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<!-- Allow outgoing network connections -->
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
+2
-1
@@ -1 +1,2 @@
|
||||
UNITY_VERSION='6000.3.0f1'
|
||||
|
||||
UNITY_VERSION='6000.1.11f1'
|
||||
@@ -1,40 +0,0 @@
|
||||
# Environment template for production deployment
|
||||
# This file defines all env vars used by docker-compose.prod.yml
|
||||
# Workflows should update their specific vars without overwriting others
|
||||
|
||||
# Container images (managed by respective build workflows)
|
||||
# Note: Shardok runs on Hetzner, deployed via shardok_arm64_build.yml
|
||||
EAGLE_IMAGE=registry.digitalocean.com/eagle0/eagle-server:latest
|
||||
ADMIN_IMAGE=registry.digitalocean.com/eagle0/admin-server:latest
|
||||
JFR_SIDECAR_IMAGE=registry.digitalocean.com/eagle0/jfr-sidecar:latest
|
||||
AUTH_IMAGE=registry.digitalocean.com/eagle0/auth-server:latest
|
||||
|
||||
# OpenAI / LLM
|
||||
OPENAI_API_KEY=
|
||||
GPT_MODEL_NAME=gpt-4o
|
||||
|
||||
# DigitalOcean Spaces (S3-compatible storage)
|
||||
EAGLE_ENABLE_S3=false
|
||||
DO_SPACES_ACCESS_KEY=
|
||||
DO_SPACES_SECRET_KEY=
|
||||
|
||||
# JWT authentication
|
||||
JWT_PRIVATE_KEY=
|
||||
|
||||
# OAuth providers
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# Shardok connection (Hetzner ARM64 server)
|
||||
SHARDOK_ADDRESS=
|
||||
SHARDOK_AUTH_TOKEN=
|
||||
|
||||
# Monitoring
|
||||
SENTRY_DSN=
|
||||
|
||||
# Email (Fastmail JMAP)
|
||||
FASTMAIL_API_TOKEN=
|
||||
FASTMAIL_FROM_EMAIL=
|
||||
FASTMAIL_FROM_NAME=
|
||||
@@ -1,59 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Update .env file without losing other variables
|
||||
# Usage: ./update-env.sh KEY1=value1 KEY2=value2 ...
|
||||
#
|
||||
# This script:
|
||||
# 1. Creates .env from template if it doesn't exist
|
||||
# 2. Updates only the specified KEY=value pairs
|
||||
# 3. Preserves all other existing values
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ENV_FILE="${ENV_FILE:-/opt/eagle0/.env}"
|
||||
TEMPLATE_FILE="${TEMPLATE_FILE:-$SCRIPT_DIR/env.template}"
|
||||
|
||||
# Create .env from template if it doesn't exist
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
if [ -f "$TEMPLATE_FILE" ]; then
|
||||
echo "Creating .env from template..."
|
||||
grep -v '^#' "$TEMPLATE_FILE" | grep -v '^$' > "$ENV_FILE"
|
||||
else
|
||||
echo "Creating empty .env..."
|
||||
touch "$ENV_FILE"
|
||||
fi
|
||||
chmod 600 "$ENV_FILE"
|
||||
fi
|
||||
|
||||
# Process each KEY=VALUE argument
|
||||
for arg in "$@"; do
|
||||
# Skip empty args
|
||||
[ -z "$arg" ] && continue
|
||||
|
||||
# Parse KEY=VALUE
|
||||
KEY="${arg%%=*}"
|
||||
VALUE="${arg#*=}"
|
||||
|
||||
# Skip if no key
|
||||
[ -z "$KEY" ] && continue
|
||||
|
||||
# Skip setting empty values (keeps existing value)
|
||||
if [ -z "$VALUE" ]; then
|
||||
echo "Skipping $KEY (empty value)"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Remove existing line for this key and add new one
|
||||
if grep -q "^${KEY}=" "$ENV_FILE" 2>/dev/null; then
|
||||
# Key exists, update it
|
||||
sed -i "s|^${KEY}=.*|${KEY}=${VALUE}|" "$ENV_FILE"
|
||||
echo "Updated $KEY"
|
||||
else
|
||||
# Key doesn't exist, add it
|
||||
echo "${KEY}=${VALUE}" >> "$ENV_FILE"
|
||||
echo "Added $KEY"
|
||||
fi
|
||||
done
|
||||
|
||||
chmod 600 "$ENV_FILE"
|
||||
echo "Done updating $ENV_FILE"
|
||||
@@ -1,288 +0,0 @@
|
||||
# Docker Compose for production deployment
|
||||
#
|
||||
# Local testing:
|
||||
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:auth_server_load
|
||||
# Run: docker compose -f docker-compose.prod.yml up
|
||||
#
|
||||
# Production deployment:
|
||||
# Run: docker compose -f docker-compose.prod.yml up -d
|
||||
#
|
||||
# Note: Shardok runs on Hetzner ARM64 server, deployed via shardok_arm64_build.yml workflow.
|
||||
|
||||
services:
|
||||
# Blue-green deployment: eagle-blue is the primary (production) instance
|
||||
# eagle-green is the staging instance for zero-downtime deployments
|
||||
# See scripts/deploy-blue-green.sh for deployment workflow
|
||||
|
||||
eagle-blue:
|
||||
image: ${EAGLE_IMAGE:-registry.digitalocean.com/eagle0/eagle-server:latest}
|
||||
container_name: eagle-blue
|
||||
command:
|
||||
- "--gpt-model-name"
|
||||
- "${GPT_MODEL_NAME:-gpt-5.1}"
|
||||
- "--shardok-interface-remote-address"
|
||||
- "${SHARDOK_ADDRESS:-shardok:40042}"
|
||||
- "--auth-service-url"
|
||||
- "auth:40033"
|
||||
ports:
|
||||
- "40032:40032"
|
||||
environment:
|
||||
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
|
||||
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
|
||||
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
|
||||
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
|
||||
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
|
||||
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
|
||||
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
|
||||
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
|
||||
# Auth token for Shardok on Hetzner (required)
|
||||
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
|
||||
# Use persistent volume for save data (users, games, etc.)
|
||||
EAGLE_SAVE_DIR: "/app/saves"
|
||||
EAGLE_ARCHIVE_DIR: "/app/archived"
|
||||
SENTRY_DSN: "${SENTRY_DSN:-}"
|
||||
SENTRY_ENVIRONMENT: "production"
|
||||
volumes:
|
||||
- ./saves:/app/saves # Game saves and user database
|
||||
- ./archived:/app/archived # Archived completed games
|
||||
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-blue jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
|
||||
- jvm-tmp:/tmp # Shared with jfr-sidecar for JVM attach socket files
|
||||
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
|
||||
depends_on:
|
||||
- auth
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "100m"
|
||||
max-file: "5"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "nc -z localhost 40032 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
eagle-green:
|
||||
image: ${EAGLE_IMAGE_NEW:-registry.digitalocean.com/eagle0/eagle-server:latest}
|
||||
container_name: eagle-green
|
||||
profiles: ["blue-green"] # Only started during blue-green deployment
|
||||
command:
|
||||
- "--gpt-model-name"
|
||||
- "${GPT_MODEL_NAME:-gpt-5.1}"
|
||||
- "--shardok-interface-remote-address"
|
||||
- "${SHARDOK_ADDRESS:-shardok:40042}"
|
||||
- "--auth-service-url"
|
||||
- "auth:40033"
|
||||
ports:
|
||||
- "40034:40032" # Different host port for staging
|
||||
environment:
|
||||
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
|
||||
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
|
||||
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
|
||||
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
|
||||
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
|
||||
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
|
||||
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
|
||||
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
|
||||
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
|
||||
EAGLE_SAVE_DIR: "/app/saves"
|
||||
EAGLE_ARCHIVE_DIR: "/app/archived"
|
||||
SENTRY_DSN: "${SENTRY_DSN:-}"
|
||||
SENTRY_ENVIRONMENT: "production"
|
||||
volumes:
|
||||
- ./saves:/app/saves # Same save directory as blue
|
||||
- ./archived:/app/archived # Same archive directory as blue
|
||||
- ./jfr:/app/jfr # JFR recordings (same as blue)
|
||||
- jvm-tmp:/tmp # Shared with jfr-sidecar-green for JVM attach socket files
|
||||
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
|
||||
depends_on:
|
||||
- auth
|
||||
restart: "no" # Don't auto-restart during deployment
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "100m"
|
||||
max-file: "5"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "nc -z localhost 40032 || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
start_period: 60s
|
||||
|
||||
# Backward compatibility alias - for scripts that reference 'eagle' service
|
||||
eagle:
|
||||
extends:
|
||||
service: eagle-blue
|
||||
|
||||
auth:
|
||||
image: ${AUTH_IMAGE:-registry.digitalocean.com/eagle0/auth-server:latest}
|
||||
container_name: auth-server
|
||||
environment:
|
||||
# gRPC port for Auth service
|
||||
AUTH_GRPC_PORT: "40033"
|
||||
# HTTP port for OAuth callbacks
|
||||
AUTH_HTTP_PORT: "8080"
|
||||
# User data persistence directory
|
||||
AUTH_DATA_DIR: "/app/data"
|
||||
# Legacy path for migrating users from Eagle (Phase 1 migration)
|
||||
AUTH_LEGACY_DATA_DIR: "/app/saves/auth"
|
||||
# OAuth provider credentials
|
||||
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
|
||||
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
|
||||
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID:-}"
|
||||
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}"
|
||||
# Server base URL for OAuth callbacks
|
||||
SERVER_BASE_URL: "${SERVER_BASE_URL:-https://prod.eagle0.net}"
|
||||
# JWT keys - PEM files in volume, bootstrapped from JWK on first run
|
||||
JWT_KEYS_PATH: "/etc/eagle0/keys"
|
||||
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
|
||||
# Fastmail JMAP API for sending invitation emails
|
||||
FASTMAIL_API_TOKEN: "${FASTMAIL_API_TOKEN:-}"
|
||||
FASTMAIL_FROM_EMAIL: "${FASTMAIL_FROM_EMAIL:-}"
|
||||
FASTMAIL_FROM_NAME: "${FASTMAIL_FROM_NAME:-}"
|
||||
# Require invitation codes for new user registration
|
||||
REQUIRE_INVITATION_CODE: "true"
|
||||
# Note: port 40033 is exposed via nginx, not directly
|
||||
volumes:
|
||||
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with Eagle
|
||||
- ./auth-data:/app/data # User database persistence
|
||||
- ./saves:/app/saves:ro # Read-only access to Eagle's saves for migration
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "nc -z localhost 40033 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# Note: Shardok runs on Hetzner ARM64 server, not in this docker-compose.
|
||||
# Configure SHARDOK_ADDRESS to point to the Hetzner instance.
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: nginx
|
||||
ports:
|
||||
- "443:443"
|
||||
- "80:80"
|
||||
- "40033:40033" # Go Auth service gRPC (Phase 2 direct client connections)
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./certbot/conf:/etc/letsencrypt:ro
|
||||
- ./certbot/www:/var/www/certbot:ro
|
||||
- ./auth:/etc/nginx/auth:ro
|
||||
depends_on:
|
||||
- admin
|
||||
# Note: nginx connects to eagle via EAGLE_ADDR (default: eagle-blue:40032)
|
||||
# For blue-green deployments, update EAGLE_ADDR in .env before switching
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
admin:
|
||||
image: ${ADMIN_IMAGE:-registry.digitalocean.com/eagle0/admin-server:latest}
|
||||
container_name: admin-server
|
||||
command:
|
||||
- "--eagle-addr"
|
||||
- "${EAGLE_ADDR:-eagle-blue:40032}" # Can be switched for blue-green
|
||||
- "--auth-addr"
|
||||
- "auth:40033"
|
||||
- "--jfr-sidecar-addr"
|
||||
- "${JFR_SIDECAR_ADDR:-jfr-sidecar:8081}" # Can be switched for blue-green
|
||||
- "--http-port"
|
||||
- "8080"
|
||||
# No external port - accessed via nginx at admin.eagle0.net
|
||||
depends_on:
|
||||
- auth
|
||||
# Note: admin connects to eagle via EAGLE_ADDR and jfr-sidecar via JFR_SIDECAR_ADDR
|
||||
# For blue-green deployments, set both in .env before switching
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://localhost:8080/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
jfr-sidecar:
|
||||
image: ${JFR_SIDECAR_IMAGE:-registry.digitalocean.com/eagle0/jfr-sidecar:latest}
|
||||
container_name: jfr-sidecar
|
||||
# Share PID namespace with Eagle to access its JVM via jcmd
|
||||
# For blue-green: use JFR_SIDECAR_ADDR=jfr-sidecar-green:8081 when green is active
|
||||
pid: "service:eagle-blue"
|
||||
volumes:
|
||||
- jvm-tmp:/tmp # Shared with Eagle for JVM attach socket files
|
||||
depends_on:
|
||||
- eagle-blue
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "2"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://localhost:8081/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
jfr-sidecar-green:
|
||||
image: ${JFR_SIDECAR_IMAGE:-registry.digitalocean.com/eagle0/jfr-sidecar:latest}
|
||||
container_name: jfr-sidecar-green
|
||||
profiles: ["blue-green"] # Only started during blue-green deployment
|
||||
# Share PID namespace with Eagle green instance
|
||||
pid: "service:eagle-green"
|
||||
volumes:
|
||||
- jvm-tmp:/tmp # Shared with Eagle for JVM attach socket files
|
||||
depends_on:
|
||||
- eagle-green
|
||||
restart: "no" # Don't auto-restart during deployment
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "2"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://localhost:8081/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
certbot:
|
||||
image: certbot/certbot
|
||||
container_name: certbot
|
||||
volumes:
|
||||
- ./certbot/conf:/etc/letsencrypt
|
||||
- ./certbot/www:/var/www/certbot
|
||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||
|
||||
volumes:
|
||||
jvm-tmp:
|
||||
# Shared /tmp for JVM attach socket files between Eagle and jfr-sidecar
|
||||
jwt-keys:
|
||||
# Shared JWT RSA keys between Eagle and auth service
|
||||
|
||||
networks:
|
||||
default:
|
||||
driver: bridge
|
||||
enable_ipv6: true
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.28.0.0/16
|
||||
- subnet: fd00:dead:beef::/48
|
||||
@@ -1,471 +0,0 @@
|
||||
# Admin Server Enhancement Plan
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines enhancements to the Go admin server (`src/main/go/net/eagle0/admin_server/`) to provide a proper web UI for game administration.
|
||||
|
||||
### Current State
|
||||
|
||||
The admin server provides a full web UI with htmx interactivity:
|
||||
- `GET /` - Redirect to games list
|
||||
- `GET /games` - Game list page (HTML)
|
||||
- `GET /games/{id}` - Game detail with action history
|
||||
- `GET /games/{id}/history` - History rows (htmx partial, infinite scroll)
|
||||
- `GET /games/{id}/action/{index}` - Action detail (htmx partial)
|
||||
- `POST /games/{id}/rewind` - Rewind game to target action
|
||||
- `GET /settings` - Settings list with live search
|
||||
- `POST /settings/update` - Update setting value
|
||||
- `GET /health` - Health check (JSON)
|
||||
- `GET /api/games` - JSON API for programmatic access
|
||||
- `GET /api/games/{id}/history` - JSON API for history
|
||||
|
||||
### Goals
|
||||
|
||||
1. **Web UI**: Replace raw JSON with an interactive HTML interface
|
||||
2. **Settings Management**: View and modify the 275+ game settings at runtime
|
||||
3. **Game Rewind**: Restore a game to a previous action count
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Technology Choice: Go Templates + htmx
|
||||
|
||||
**Rationale:**
|
||||
- Single binary deployment (no separate frontend build)
|
||||
- htmx provides interactivity without JavaScript framework complexity
|
||||
- Familiar HTML/CSS, minimal learning curve
|
||||
- Excellent for admin tools where SEO and bundle size don't matter
|
||||
|
||||
**Alternatives Considered:**
|
||||
- React/Vue SPA: Adds build complexity, separate deployment artifact
|
||||
- Server-side only: Less interactive, full page reloads
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
src/main/go/net/eagle0/admin_server/
|
||||
├── admin_server.go # Main entry point, HTTP routes
|
||||
├── handlers/
|
||||
│ ├── games.go # Game list and detail handlers
|
||||
│ ├── settings.go # Settings list and update handlers
|
||||
│ └── rewind.go # Game rewind handlers
|
||||
├── templates/
|
||||
│ ├── layout.html # Base layout with nav, htmx includes
|
||||
│ ├── games/
|
||||
│ │ ├── list.html # Game list page
|
||||
│ │ ├── detail.html # Single game view with history
|
||||
│ │ └── history.html # Partial for history table (htmx)
|
||||
│ ├── settings/
|
||||
│ │ ├── list.html # Settings list with search/filter
|
||||
│ │ └── edit.html # Inline edit partial (htmx)
|
||||
│ └── rewind/
|
||||
│ └── confirm.html # Rewind confirmation modal
|
||||
├── static/
|
||||
│ ├── style.css # Minimal CSS (Pico CSS or similar)
|
||||
│ └── htmx.min.js # htmx library
|
||||
└── BUILD.bazel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: Web UI
|
||||
|
||||
### Routes
|
||||
|
||||
| Route | Method | Description |
|
||||
|-------|--------|-------------|
|
||||
| `/` | GET | Redirect to `/games` |
|
||||
| `/games` | GET | Game list page (HTML) |
|
||||
| `/games/{id}` | GET | Game detail page with history |
|
||||
| `/games/{id}/history` | GET | History partial (htmx, for infinite scroll) |
|
||||
| `/api/games` | GET | JSON API (existing, keep for programmatic access) |
|
||||
| `/api/games/{id}/history` | GET | JSON API (existing) |
|
||||
|
||||
### Game List Page
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Eagle Admin [Settings] [Health] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Running Games (3) │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Game abc123f Round 45 │ │
|
||||
│ │ Players: Liu Bei (Human), Cao Cao (AI), Sun Quan │ │
|
||||
│ │ Actions: 1,234 [View] [Rewind]│ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Game def456a Round 12 │ │
|
||||
│ │ Players: Test Player (Human) │ │
|
||||
│ │ Actions: 456 [View] [Rewind]│ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Game Detail Page
|
||||
|
||||
Shows game info and scrollable action history:
|
||||
- **Reverse chronological order**: Most recent actions displayed first
|
||||
- Each action shows: index, type, round ID
|
||||
- **Clickable actions**: Clicking an action row expands to show JSON representation of the full action data
|
||||
- "Rewind to here" button on each action row
|
||||
- Infinite scroll loads more history via htmx (loading older actions as user scrolls down)
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
1. **Embed static files**: Use `//go:embed` to bundle templates and static files
|
||||
2. **Template functions**: Add helpers for formatting (hex IDs, timestamps, action summaries)
|
||||
3. **CSS framework**: Use Pico CSS (~10KB) for clean defaults without classes
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: Settings Management
|
||||
|
||||
### New gRPC Endpoints (Eagle Server)
|
||||
|
||||
Add to `eagle.proto`:
|
||||
|
||||
```protobuf
|
||||
message Setting {
|
||||
string name = 1;
|
||||
string type = 2; // "Int" or "Double"
|
||||
string value = 3; // Current value as string
|
||||
string default_value = 4; // Default from BUILD.bazel
|
||||
string description = 5; // Optional, for UI hints
|
||||
}
|
||||
|
||||
message GetSettingsRequest {
|
||||
string filter = 1; // Optional name filter (substring match)
|
||||
}
|
||||
|
||||
message GetSettingsResponse {
|
||||
repeated Setting settings = 1;
|
||||
}
|
||||
|
||||
message UpdateSettingRequest {
|
||||
string name = 1;
|
||||
string value = 2;
|
||||
}
|
||||
|
||||
message UpdateSettingResponse {
|
||||
Setting setting = 1; // Updated setting
|
||||
string error = 2; // Empty on success
|
||||
}
|
||||
|
||||
service Eagle {
|
||||
// ... existing methods ...
|
||||
rpc GetSettings(GetSettingsRequest) returns (GetSettingsResponse);
|
||||
rpc UpdateSetting(UpdateSettingRequest) returns (UpdateSettingResponse);
|
||||
}
|
||||
```
|
||||
|
||||
### Eagle Server Implementation
|
||||
|
||||
Create a settings registry that:
|
||||
1. Discovers all `IntSetting` and `DoubleSetting` instances via reflection or explicit registration
|
||||
2. Provides get/set by name
|
||||
3. Validates types on update
|
||||
|
||||
```scala
|
||||
// src/main/scala/net/eagle0/eagle/library/settings/SettingsRegistry.scala
|
||||
object SettingsRegistry {
|
||||
private val settings: Map[String, Either[IntSetting, DoubleSetting]] = Map(
|
||||
"ActionVigorCost" -> Left(ActionVigorCost),
|
||||
"BaseFoodBuyPrice" -> Right(BaseFoodBuyPrice),
|
||||
// ... register all 275 settings
|
||||
)
|
||||
|
||||
def getAll(filter: Option[String]): Seq[Setting] = ...
|
||||
def get(name: String): Option[Setting] = ...
|
||||
def update(name: String, value: String): Either[String, Setting] = ...
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative: Code generation**
|
||||
|
||||
Rather than manually registering 275 settings, modify `setting_rule.bzl` to generate a registry file during build.
|
||||
|
||||
### Admin Server Routes
|
||||
|
||||
| Route | Method | Description |
|
||||
|-------|--------|-------------|
|
||||
| `/settings` | GET | Settings list page with search |
|
||||
| `/settings/{name}` | GET | Single setting detail (htmx partial) |
|
||||
| `/settings/{name}` | PUT | Update setting value |
|
||||
| `/api/settings` | GET | JSON API |
|
||||
| `/api/settings/{name}` | PUT | JSON API |
|
||||
|
||||
### Settings UI
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Eagle Admin [Games] [Health] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Settings [Search: __________ ] │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ ActionVigorCost (Int) │ │
|
||||
│ │ Current: [15 ] Default: 15 [Save] │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ BaseFoodBuyPrice (Double) │ │
|
||||
│ │ Current: [0.5 ] Default: 0.5 [Save] │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ... (275 settings, virtualized/paginated) ... │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Considerations
|
||||
|
||||
1. **Persistence**: Settings changes are in-memory only. Document that restarts reset to defaults.
|
||||
2. **Validation**: Validate numeric ranges where applicable (e.g., percentages 0-100)
|
||||
3. **Categories**: Consider grouping settings by prefix (AI*, Combat*, Economy*, etc.)
|
||||
4. **Audit log**: Log setting changes with timestamp for debugging
|
||||
|
||||
---
|
||||
|
||||
## Feature 3: Game Rewind
|
||||
|
||||
### Concept
|
||||
|
||||
Restore a game to a previous point in its action history. This is useful for:
|
||||
- Debugging issues that occurred at a specific point
|
||||
- Testing "what if" scenarios
|
||||
- Recovering from bugs that corrupted state
|
||||
|
||||
### New gRPC Endpoint
|
||||
|
||||
Add to `eagle.proto`:
|
||||
|
||||
```protobuf
|
||||
message RewindGameRequest {
|
||||
int64 game_id = 1;
|
||||
int32 target_action_count = 2; // Rewind to state after this many actions
|
||||
}
|
||||
|
||||
message RewindGameResponse {
|
||||
bool success = 1;
|
||||
string error = 2;
|
||||
int32 new_action_count = 3;
|
||||
int32 disconnected_clients = 4; // Number of clients that were disconnected
|
||||
}
|
||||
|
||||
service Eagle {
|
||||
// ... existing methods ...
|
||||
rpc RewindGame(RewindGameRequest) returns (RewindGameResponse);
|
||||
}
|
||||
```
|
||||
|
||||
### Eagle Server Implementation
|
||||
|
||||
The `GameHistory` already stores `ActionWithResultingState` for each action, which includes the `GameState` after that action. Rewinding means:
|
||||
|
||||
1. **Validate**: Check that `target_action_count` is within valid range (0 to current count)
|
||||
2. **Get target state**: Retrieve `GameState` at target action count from history
|
||||
3. **Disconnect clients**: Close all human player connections (they'll need to reconnect)
|
||||
4. **Replace engine**: Create new `EngineImpl` with target state and truncated history
|
||||
5. **Reset AI state**: Clear any cached AI state that depends on current game state
|
||||
|
||||
```scala
|
||||
// GameController.scala (pseudocode)
|
||||
def rewindTo(targetActionCount: Int): Either[String, RewindResult] = {
|
||||
if (targetActionCount < 0 || targetActionCount > engine.history.count)
|
||||
return Left(s"Invalid action count: $targetActionCount")
|
||||
|
||||
// Get state at target point
|
||||
val targetState = engine.history.stateAt(targetActionCount)
|
||||
val truncatedHistory = engine.history.truncateTo(targetActionCount)
|
||||
|
||||
// Disconnect all human clients
|
||||
val disconnectedCount = humanClients.length
|
||||
humanClients.foreach(_.disconnect("Game rewound by admin"))
|
||||
|
||||
// Create new engine at target state
|
||||
val newEngine = EngineImpl(
|
||||
gameId = engine.gameId,
|
||||
currentState = targetState,
|
||||
history = truncatedHistory,
|
||||
// ... other fields
|
||||
)
|
||||
|
||||
// Replace controller's engine
|
||||
this.engine = newEngine
|
||||
|
||||
Right(RewindResult(targetActionCount, disconnectedCount))
|
||||
}
|
||||
```
|
||||
|
||||
### GameHistory Enhancement
|
||||
|
||||
Add method to get state at a specific action count:
|
||||
|
||||
```scala
|
||||
trait GameHistory {
|
||||
// ... existing methods ...
|
||||
|
||||
def stateAt(actionCount: Int): GameState = {
|
||||
if (actionCount == 0) initialState
|
||||
else all(actionCount - 1).resultingState
|
||||
}
|
||||
|
||||
def truncateTo(actionCount: Int): GameHistory = {
|
||||
GameHistoryImpl(
|
||||
initialState = initialState,
|
||||
actions = all.take(actionCount)
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Admin Server Route
|
||||
|
||||
| Route | Method | Description |
|
||||
|-------|--------|-------------|
|
||||
| `/games/{id}/rewind` | POST | Rewind game (form: `target_action_count`) |
|
||||
| `/games/{id}/rewind/confirm` | GET | Confirmation modal (htmx partial) |
|
||||
|
||||
### Rewind UI Flow
|
||||
|
||||
1. User views game history
|
||||
2. User clicks "Rewind to here" on an action row
|
||||
3. Confirmation modal appears via htmx:
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Rewind Game abc123f? │
|
||||
│ │
|
||||
│ This will: │
|
||||
│ • Restore to action 456 (Round 23) │
|
||||
│ • Discard 778 subsequent actions │
|
||||
│ • Disconnect 2 connected players │
|
||||
│ │
|
||||
│ This cannot be undone. │
|
||||
│ │
|
||||
│ [Cancel] [Rewind] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
4. On confirm, POST to `/games/{id}/rewind`
|
||||
5. Success: redirect to game detail showing new state
|
||||
6. Error: show error message
|
||||
|
||||
### Safety Considerations
|
||||
|
||||
1. **No undo**: Rewinding discards history. Consider optional backup before rewind.
|
||||
2. **Client disconnect**: All connected clients are forcibly disconnected.
|
||||
3. **AI state**: Ensure AI clients restart cleanly after rewind.
|
||||
4. **Concurrent access**: Lock game during rewind to prevent race conditions.
|
||||
5. **Authorization**: In production, require admin authentication.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Web UI Foundation
|
||||
|
||||
**Status: Complete**
|
||||
|
||||
1. ✅ Set up Go templates with `embed`
|
||||
2. ✅ Add Pico CSS and htmx
|
||||
3. ✅ Create base layout with navigation
|
||||
4. ✅ Convert `/games` to HTML with styling
|
||||
5. ✅ Add game detail page with history table
|
||||
6. ✅ Implement htmx infinite scroll for history
|
||||
7. ✅ Reverse history order (most recent first)
|
||||
8. ✅ Clickable action rows that expand to show JSON representation
|
||||
9. ✅ Add `/games/{id}/action/{index}` endpoint for fetching action details
|
||||
|
||||
**Deliverable**: Browsable game list and history in HTML with clickable action details
|
||||
|
||||
### Phase 2: Settings Management
|
||||
|
||||
**Status: Complete**
|
||||
|
||||
1. ✅ Add `GetSettings` to `eagle.proto` (uses existing `AddSettings` for updates)
|
||||
2. ✅ Add `getAllSettings` method to auto-generated `SettingsLoader`
|
||||
3. ✅ Implement `getSettings` in `EagleServiceImpl`
|
||||
4. ✅ Create settings list page with live search
|
||||
5. ✅ Add inline editing with htmx
|
||||
6. ✅ Modified settings are highlighted
|
||||
|
||||
**Deliverable**: View and edit settings via admin UI
|
||||
|
||||
### Phase 3: Game Rewind
|
||||
|
||||
**Status: Complete**
|
||||
|
||||
1. ✅ Add `RewindGame` to `eagle.proto`
|
||||
2. ✅ Implement `stateAt` and `truncateTo` in `GameHistory`
|
||||
3. ✅ Implement rewind logic in `Engine` and `GameController`
|
||||
4. ✅ Add rewind confirmation (htmx `hx-confirm` dialog)
|
||||
5. ✅ Handle client disconnection gracefully
|
||||
6. ✅ Add rewind button to history rows
|
||||
7. ✅ Implement `rewindGame` in `GamesManager` and `EagleServiceImpl`
|
||||
8. ✅ Add admin server `/games/{id}/rewind` POST handler
|
||||
9. ✅ Add success/error feedback UI
|
||||
|
||||
**Deliverable**: Rewind games to any previous action
|
||||
|
||||
### Phase 4: Polish
|
||||
|
||||
**Status: Not Started**
|
||||
|
||||
#### High Priority
|
||||
1. **Add tests for rewind functionality**
|
||||
- `PersistedHistory.truncateTo` (handles complex persisted vs recent logic)
|
||||
- `InMemoryHistory.truncateTo`
|
||||
- `EngineImpl.rewindTo`
|
||||
- `GameController.rewindTo`
|
||||
- `GamesManager.rewindGame`
|
||||
|
||||
2. **Improve action history display**
|
||||
- Human-readable action type names (e.g., "New Round" instead of "NewRoundAction")
|
||||
- Show acting faction/province when available
|
||||
- Action summaries from the `summary` field in `GameHistoryEntry`
|
||||
|
||||
#### Medium Priority
|
||||
3. **Settings improvements**
|
||||
- Group settings by category prefix (AI*, Combat*, Economy*, etc.)
|
||||
- Show setting descriptions where available
|
||||
- Pagination for large settings lists
|
||||
|
||||
4. **Error handling improvements**
|
||||
- Better error messages on failed operations
|
||||
- Retry logic for transient gRPC failures
|
||||
|
||||
#### Low Priority (Nice to Have)
|
||||
5. **Basic auth** - HTTP Basic Auth or OAuth for production use
|
||||
6. **Audit logging** - Log admin actions with timestamps
|
||||
7. **Documentation** - Usage guide, deployment notes
|
||||
|
||||
#### Future Considerations
|
||||
- Game creation from admin UI
|
||||
- Player management (view connected players, force disconnect)
|
||||
- Export game history to file
|
||||
- Metrics/stats dashboard
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
The admin server is intended for local/trusted network use only. For production:
|
||||
|
||||
1. **Do not expose to public internet** without authentication
|
||||
2. Consider adding HTTP Basic Auth or OAuth
|
||||
3. Run on internal network or behind VPN
|
||||
4. Log all admin actions for audit trail
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Settings persistence**: Should we add optional persistence to disk/database?
|
||||
2. **Game snapshots**: Should rewind create a backup first?
|
||||
3. **Multi-admin**: Need locking if multiple admins access simultaneously?
|
||||
4. **Shardok settings**: Are there Shardok (C++) settings to expose too?
|
||||
@@ -1,240 +0,0 @@
|
||||
# Eagle0 Media Asset Audit
|
||||
|
||||
This document catalogs all media assets in the Unity project for licensing review.
|
||||
|
||||
**Total Assets:** ~12,500 files | **Size:** 1.4 GB
|
||||
|
||||
---
|
||||
|
||||
## Summary by Category
|
||||
|
||||
| Category | Count | Notes |
|
||||
|----------|-------|-------|
|
||||
| Images | 10,637 | Mostly PNG icons and UI sprites |
|
||||
| Audio | 1,778 | 26 music tracks + 1,752 sound effects |
|
||||
| 3D Models | 52 | Bridge pack only |
|
||||
| Fonts | 16 | TTF files |
|
||||
|
||||
---
|
||||
|
||||
## 1. Purchased Asset Store Packages
|
||||
|
||||
These are commercial Unity Asset Store purchases tied to your account:
|
||||
|
||||
### 4000_Fantasy_Icons
|
||||
- **Location:** `Assets/4000_Fantasy_Icons/`
|
||||
- **Size:** 495 MB (5,621 PNG files)
|
||||
- **Contents:** Icons for armor, weapons, skills, resources
|
||||
- **License:** Unity Asset Store (check invoice/account)
|
||||
|
||||
### GUI Pro Kit Fantasy RPG
|
||||
- **Location:** `Assets/GUI Pro Kit Fantasy RPG/`
|
||||
- **Size:** 117 MB (3,755 PNG files)
|
||||
- **Contents:** UI sprites, animations, prefabs
|
||||
- **Includes fonts:** Alata-Regular.ttf, JosefinSans-Bold.ttf
|
||||
- **License:** Unity Asset Store
|
||||
|
||||
### Modern UI Pack v4.2.0
|
||||
- **Location:** `Assets/Modern UI Pack/`
|
||||
- **Size:** 40 MB (191 PNG files)
|
||||
- **Author:** Michsky (support@michsky.com)
|
||||
- **Website:** https://www.michsky.com
|
||||
- **Includes fonts:** Open Sans family (12 variants)
|
||||
- **License:** Unity Asset Store
|
||||
|
||||
### Pixel Fonts Megapack
|
||||
- **Location:** `Assets/Pixel Fonts Megapack/`
|
||||
- **Publisher ID:** 17384
|
||||
- **Author:** @pixelmush_ on Twitter
|
||||
- **Asset Store Link:** http://u3d.as/w4v
|
||||
- **License:** Unity Asset Store
|
||||
|
||||
### TileableBridgePack
|
||||
- **Location:** `Assets/TileableBridgePack/`
|
||||
- **Size:** 3.1 MB (52 FBX models)
|
||||
- **Contents:** Bridge construction pieces
|
||||
- **License:** Unity Asset Store
|
||||
|
||||
### Fantasy Interface Sounds
|
||||
- **Location:** `Assets/Fantasy Interface Sounds/`
|
||||
- **Count:** 320 WAV files
|
||||
- **Contents:** UI sounds (bag, book, coins, dice, etc.)
|
||||
- **License:** Unity Asset Store (verify)
|
||||
|
||||
### Medieval Combat Sounds
|
||||
- **Location:** `Assets/Medieval Combat Sounds/`
|
||||
- **Count:** 1,072 WAV files
|
||||
- **Contents:** Footsteps, swings, shields, weapons, magic
|
||||
- **License:** Unity Asset Store (verify)
|
||||
|
||||
### Magic Spells Sound Effects LITE
|
||||
- **Location:** `Assets/Magic Spells Sound Effects LITE/`
|
||||
- **Count:** 254 WAV files
|
||||
- **Contents:** Spell casting, element effects
|
||||
- **Note:** "LITE" version - check if restrictions apply
|
||||
- **License:** Unity Asset Store (verify)
|
||||
|
||||
---
|
||||
|
||||
## 2. Creative Commons Music (Properly Licensed)
|
||||
|
||||
**Location:** `Assets/Resources/Music/`
|
||||
**Documentation:** `Music Credits.txt` (attribution file exists)
|
||||
|
||||
All 26 tracks have CC licenses with proper attribution:
|
||||
|
||||
| Track | Artist | License |
|
||||
|-------|--------|---------|
|
||||
| A Robust Crew | Darren Curtis | CC BY 3.0 |
|
||||
| Asian Graveyard | Darren Curtis | CC BY 3.0 |
|
||||
| Fall From Grace | Darren Curtis | CC BY 3.0 |
|
||||
| Samurai Sake Showdown | Darren Curtis | CC BY 3.0 |
|
||||
| Deflector | Ghostrifter Official | CC BY-SA 3.0 |
|
||||
| Chase | Alexander Nakarada | CC BY 4.0 |
|
||||
| Wintersong | Alexander Nakarada | CC BY 4.0 |
|
||||
| One Bard Band | Alexander Nakarada | CC BY 4.0 |
|
||||
| Now We Ride | Alexander Nakarada | CC BY 4.0 |
|
||||
| The Northern Path | Alexander Nakarada | CC BY 4.0 |
|
||||
| Victory | MaxKoMusic | CC BY-SA 3.0 |
|
||||
| Sakuya2 | PeriTune | CC BY 3.0 |
|
||||
| Under The Sun | Keys of Moon | CC BY 4.0 |
|
||||
| One Piece of Summer | Keys of Moon | CC BY 4.0 |
|
||||
| Fluffing a Duck | Kevin MacLeod | CC BY 3.0 |
|
||||
| Space Jazz | Kevin MacLeod | CC BY 3.0 |
|
||||
| The Ice Giants | Kevin MacLeod | CC BY 4.0 |
|
||||
| Epic Cinematic Trailer ELITE | Alex-Productions | CC BY 3.0 |
|
||||
| Push | Alex-Productions | CC BY 3.0 |
|
||||
| Virus | Alex-Productions | CC BY 3.0 |
|
||||
| Duel | Makai Symphony | CC BY-SA 3.0 |
|
||||
| Dragon Castle | Makai Symphony | CC BY-SA 3.0 |
|
||||
| Durandal | Makai Symphony | CC BY-SA 3.0 |
|
||||
|
||||
**Tracks without specific license (verify):**
|
||||
- Market Day
|
||||
- Shopping List
|
||||
- Medieval: Victory Theme
|
||||
- Tracks by Dima Koltsov (AUDIUS): No Time for Greatness, Warriors of Demacia, Forest Queen Tale, Valor, Clouds
|
||||
|
||||
---
|
||||
|
||||
## 3. CC0 / Public Domain Assets
|
||||
|
||||
### SimpleFileBrowser Icons
|
||||
- **Location:** `Assets/Plugins/SimpleFileBrowser/Sprites/FileIcons/`
|
||||
- **License:** CC0 (documented in LICENSE.txt)
|
||||
- **Source:** pngrepo.com
|
||||
- **Items:** Archive, Audio, Default, Drive, Folder, Image, PDF, Text, Video icons
|
||||
|
||||
---
|
||||
|
||||
## 4. Potentially Problematic Assets (Review Needed)
|
||||
|
||||
### Stock Images (Possible License Issues)
|
||||
These appear to be stock images that may have been used as placeholders:
|
||||
|
||||
| File | Concern |
|
||||
|------|---------|
|
||||
| ~~`Assets/Eagle/79066358-stock-illustration-raster-illustration-medieval-purse-bag...jpg`~~ | **DELETED** (2025-01-04) |
|
||||
| ~~`Assets/Images/kisspng-hammer-hand-saws-tool-clip-art...jpg`~~ | **DELETED** (2025-01-04) |
|
||||
| ~~`Assets/Images/lee-ermy-cropped.jpg`~~ | **DELETED** (2025-01-04) |
|
||||
| ~~`Assets/Eagle/images.jpeg`~~ | **DELETED** (2025-01-04) |
|
||||
|
||||
### Clip Art (Unknown License)
|
||||
| File | Concern |
|
||||
|------|---------|
|
||||
| `Assets/Shardok/commandImages/bridge.png` | Clip art style wooden bridge, unknown source - **needs replacement** |
|
||||
| `Assets/Images/startFire.png` | Icon, unknown source - **needs verification or replacement** |
|
||||
|
||||
### Shardok Sound Effects
|
||||
- **Location:** `Assets/Shardok/soundEffects/`
|
||||
- **Count:** 56 MP3 files
|
||||
- **Contents:** Spell effects, movement, combat sounds
|
||||
- **Status:** Unknown origin - may be custom or need verification
|
||||
|
||||
### Free Icons
|
||||
- **Location:** `Assets/free_icons/`
|
||||
- **Count:** 8 PNG weather icons
|
||||
- **Status:** Verify "free" means commercially usable
|
||||
|
||||
### Terrain Hexes
|
||||
- **Location:** `Assets/Terrain Hexes/`
|
||||
- **Count:** 85 PNG files
|
||||
- **Status:** Unknown source - verify licensing
|
||||
|
||||
### StrategyGameIcons
|
||||
- **Location:** `Assets/StrategyGameIcons/`
|
||||
- **Count:** 138 PNG files
|
||||
- **Status:** Unknown source - verify licensing
|
||||
|
||||
---
|
||||
|
||||
## 5. Fonts
|
||||
|
||||
| Font | Location | License |
|
||||
|------|----------|---------|
|
||||
| Open Sans (12 variants) | Modern UI Pack | Apache 2.0 (Google Font) |
|
||||
| Alata-Regular | GUI Pro Kit | SIL OFL (Google Font) |
|
||||
| JosefinSans-Bold | GUI Pro Kit | SIL OFL (Google Font) |
|
||||
| LiberationSans | TextMesh Pro | SIL OFL |
|
||||
| NotoColorEmoji | Assets root | SIL OFL (Google) |
|
||||
| Stoke-Light, Stoke-Regular | Assets root | SIL OFL (Google Font) |
|
||||
|
||||
All fonts appear to be open-source Google Fonts or Liberation fonts - should be fine.
|
||||
|
||||
---
|
||||
|
||||
## 6. Third-Party Code Packages
|
||||
|
||||
NuGet packages in `Assets/Packages/` all include LICENSE.TXT files:
|
||||
- Microsoft.Extensions.* - MIT License
|
||||
- System.* - MIT License
|
||||
- Grpc.* - Apache 2.0
|
||||
|
||||
---
|
||||
|
||||
## Action Items
|
||||
|
||||
### Must Verify Before Opening Public Access:
|
||||
|
||||
1. ~~**Stock images** - The JPG files with stock image filenames need review.~~ **DONE** - Deleted lee-ermy, kisspng, stock-illustration, Yosemite Sam, and images.jpeg (2025-01-04)
|
||||
|
||||
2. **Clip art images** - Unknown license, need replacement with properly licensed alternatives:
|
||||
- `Assets/Shardok/commandImages/bridge.png` - wooden bridge icon
|
||||
- `Assets/Images/startFire.png` - fire icon
|
||||
|
||||
3. **Shardok sound effects** - 56 MP3 files of unknown origin. Either:
|
||||
- Document their source
|
||||
- Replace with known-licensed alternatives
|
||||
- Confirm they were custom-created
|
||||
|
||||
4. **Terrain Hexes** - 85 hex tiles of unknown source
|
||||
- **TODO:** Confirm if this is a Unity Asset Store purchase (owner believes it is)
|
||||
|
||||
5. **StrategyGameIcons** - 138 icons of unknown source
|
||||
- **TODO:** Investigate origin - check Unity Asset Store purchase history
|
||||
|
||||
6. **AUDIUS music tracks** - Verify Dima Koltsov tracks are licensed for commercial use
|
||||
|
||||
7. **Discord logo** (`Eagle/Discord-Logo-Blurple.png`) - Likely fine for "Login with Discord" button per Discord brand guidelines, but verify usage complies with their terms
|
||||
|
||||
### Already Safe:
|
||||
|
||||
- All Asset Store purchases (license tied to your account)
|
||||
- CC-licensed music (attribution in Music Credits.txt)
|
||||
- CC0 SimpleFileBrowser icons
|
||||
- Google Fonts / Liberation fonts
|
||||
- NuGet packages
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
Before removing HTTP basic auth:
|
||||
|
||||
1. ~~Delete or replace the 4 suspicious JPG/JPEG files in `Assets/Eagle/` and `Assets/Images/`~~ **DONE** (2025-01-04)
|
||||
2. Replace clip art images (`bridge.png`, `startFire.png`) with properly licensed alternatives (e.g., from [game-icons.net](https://game-icons.net) CC BY 3.0)
|
||||
3. Verify source of `Assets/Shardok/soundEffects/` MP3s
|
||||
4. Verify source of `Assets/Terrain Hexes/` and `Assets/StrategyGameIcons/`
|
||||
5. If any are from early development with unclear licensing, replace them
|
||||
|
||||
The bulk of your assets (95%+) are properly licensed Asset Store purchases or CC content.
|
||||
@@ -1,280 +0,0 @@
|
||||
# CommandProto Usage Analysis in shardok/ai
|
||||
|
||||
This document analyzes all remaining usages of `CommandProto` (protocol buffer representation) in the AI code and identifies opportunities to eliminate proto conversion by using `ShardokCommand` directly.
|
||||
|
||||
## Summary
|
||||
|
||||
**Total CommandProto usages found:** 42 locations across 9 files
|
||||
|
||||
**Eliminated:** 6 usages (14%) - ✅ **Phase 1 Complete**
|
||||
**Can be eliminated:** ~14 usages (33%)
|
||||
**Must keep (for now):** ~22 usages (53%)
|
||||
|
||||
---
|
||||
|
||||
## Files with CommandProto Usage
|
||||
|
||||
### 1. AICommandFilter.cpp (6 usages) - ✅ **COMPLETED** (PR #4505)
|
||||
**Location:** Lines 146, 189, 252, 356, 387, 428
|
||||
|
||||
**Original usage:**
|
||||
```cpp
|
||||
const auto cmdProto = cmd.GetCommandProto();
|
||||
if (!cmdProto.has_target()) { ... }
|
||||
const auto& targetCoords = cmdProto.target();
|
||||
if (!cmdProto.has_actor()) { ... }
|
||||
const auto unitId = cmdProto.actor().value();
|
||||
```
|
||||
|
||||
**Replaced with:**
|
||||
```cpp
|
||||
const int targetRow = cmd.GetTargetRow();
|
||||
const int targetCol = cmd.GetTargetColumn();
|
||||
if (targetRow < 0 || targetCol < 0) {
|
||||
throw ShardokInternalErrorException("Command missing required target");
|
||||
}
|
||||
const Coords targetCoords(targetRow, targetCol);
|
||||
|
||||
const int actorId = cmd.GetActorUnitId();
|
||||
if (actorId < 0) {
|
||||
throw ShardokInternalErrorException("Command missing required actor");
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** ✅ **ELIMINATED** - Replaced with direct accessors + exception handling
|
||||
**Impact:** Eliminated 6 proto conversions in hot path (command filtering)
|
||||
**Completed:** Phase 1, PR #4505
|
||||
|
||||
---
|
||||
|
||||
### 2. ShardokAIClient.cpp (8 usages)
|
||||
**Location:** Lines 83, 86, 87, 102, 105, 237, 261, 311, 356
|
||||
|
||||
**Usage breakdown:**
|
||||
|
||||
#### a) Command validation (lines 83-87)
|
||||
```cpp
|
||||
void CheckCommand(const CommandProto &realDescriptor, const CommandProto &guessedDescriptor) {
|
||||
differencer.IgnoreField(CommandProto::descriptor()->FindFieldByNumber(
|
||||
CommandProto::kFollowUpCommandTypesFieldNumber));
|
||||
```
|
||||
**Status:** ❌ **MUST KEEP** - Uses protobuf reflection for comparison
|
||||
**Reason:** Comparing proto messages for correctness checking requires proto API
|
||||
|
||||
#### b) GetAvailableCommandProtos calls (lines 105, 356)
|
||||
```cpp
|
||||
const auto guessedCommands = guessedEngine.GetAvailableCommandProtos(playerId, false);
|
||||
if (const auto &availableCommands = engine.GetAvailableCommandProtos(playerId, false);
|
||||
```
|
||||
**Status:** ✅ **CAN REPLACE** - Should use `GetAvailableCommandsForAIPlayer()` instead
|
||||
**Impact:** This is a major conversion point - converts entire command list to protos
|
||||
**Priority:** HIGH (converts all commands to proto unnecessarily)
|
||||
|
||||
#### c) Strategy selector methods (lines 102, 237, 261, 311)
|
||||
```cpp
|
||||
const vector<CommandProto> &realAvailableCommands) const -> CommandChoiceResults
|
||||
```
|
||||
**Status:** ✅ **CAN REPLACE** - Depends on fixing strategy selector signatures
|
||||
**Priority:** MEDIUM (depends on other refactors)
|
||||
|
||||
---
|
||||
|
||||
### 3. IterativeDeepeningAI.cpp/hpp (4 usages)
|
||||
**Location:** Lines 41, 272 (cpp), 73, 96 (hpp)
|
||||
|
||||
**Current usage:**
|
||||
```cpp
|
||||
const std::vector<CommandProto>& commands,
|
||||
```
|
||||
|
||||
**Status:** ✅ **CAN REPLACE** - These methods should accept `CommandListSPtr` instead
|
||||
**Impact:** Major - this is the main AI search algorithm
|
||||
**Priority:** HIGH (core AI algorithm)
|
||||
|
||||
**Note:** IterativeDeepeningAI already receives commands as proto vectors. The conversion happens upstream at the entry point. Need to trace back to find where `GetAvailableCommandProtos` is called.
|
||||
|
||||
---
|
||||
|
||||
### 4. AIFleeDecisionCalculator.cpp/hpp (6 usages)
|
||||
**Location:** Lines 17, 38, 39, 62, 63 (hpp), 18, 19, 137, 138 (cpp)
|
||||
|
||||
**Current usage:**
|
||||
```cpp
|
||||
const vector<CommandProto>& availableCommands,
|
||||
const vector<CommandProto>::const_iterator& fleeCommand,
|
||||
```
|
||||
|
||||
**Status:** ✅ **CAN REPLACE** - Should use `CommandListSPtr` and indices instead
|
||||
**Impact:** Flee decision logic could avoid proto conversion
|
||||
**Priority:** MEDIUM
|
||||
|
||||
---
|
||||
|
||||
### 5. AIAttackerStrategySelector.cpp/hpp (2 usages)
|
||||
**Location:** Line 30 in both files
|
||||
|
||||
**Current usage:**
|
||||
```cpp
|
||||
const vector<CommandProto>& availableCommands) -> AIStrategy
|
||||
```
|
||||
|
||||
**Status:** ⚠️ **PARTIALLY REPLACEABLE** - Currently doesn't use the commands parameter
|
||||
**Current implementation:**
|
||||
```cpp
|
||||
const vector<CommandProto>& /*availableCommands*/) -> AIStrategy {
|
||||
// Parameter is commented out - not used!
|
||||
return AIStrategy::DEFAULT;
|
||||
}
|
||||
```
|
||||
**Priority:** LOW (parameter unused, but signature should be consistent)
|
||||
|
||||
---
|
||||
|
||||
### 6. AICommandEvaluator.hpp (1 usage)
|
||||
**Location:** Line 27
|
||||
|
||||
**Current usage:**
|
||||
```cpp
|
||||
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
|
||||
```
|
||||
|
||||
**Status:** ⚠️ **CHECK USAGE** - Type alias, need to check if used
|
||||
**Priority:** LOW (just a type alias)
|
||||
|
||||
---
|
||||
|
||||
### 7. AIScoreCalculator.hpp (1 usage)
|
||||
**Location:** Line 24
|
||||
|
||||
**Current usage:**
|
||||
```cpp
|
||||
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
|
||||
```
|
||||
|
||||
**Status:** ⚠️ **CHECK USAGE** - Type alias, need to check if used
|
||||
**Priority:** LOW (just a type alias)
|
||||
|
||||
---
|
||||
|
||||
### 8. AIWaterCrossingCommandChooser.hpp (1 usage)
|
||||
**Location:** Line 20
|
||||
|
||||
**Current usage:**
|
||||
```cpp
|
||||
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
|
||||
```
|
||||
|
||||
**Status:** ⚠️ **CHECK USAGE** - Type alias, need to check if used
|
||||
**Priority:** LOW (just a type alias)
|
||||
|
||||
---
|
||||
|
||||
## Key Conversion Points (Entry Points)
|
||||
|
||||
### ShardokEngine::GetAvailableCommandProtos()
|
||||
This method converts the entire command list from `CommandListSPtr` to `vector<CommandProto>`.
|
||||
|
||||
**Current flow:**
|
||||
```
|
||||
ShardokEngine::GetAvailableCommandsForAIPlayer() → CommandListSPtr
|
||||
↓ (conversion)
|
||||
ShardokEngine::GetAvailableCommandProtos() → vector<CommandProto>
|
||||
↓
|
||||
AI algorithms (IterativeDeepeningAI, etc.)
|
||||
```
|
||||
|
||||
**Desired flow:**
|
||||
```
|
||||
ShardokEngine::GetAvailableCommandsForAIPlayer() → CommandListSPtr
|
||||
↓ (no conversion!)
|
||||
AI algorithms use CommandSPtr directly
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recommendations by Priority
|
||||
|
||||
### HIGH Priority (Performance-critical hot paths)
|
||||
|
||||
1. **AICommandFilter.cpp (6 usages)**
|
||||
- Replace `cmd.GetCommandProto()` with direct accessor methods
|
||||
- Use `GetActorUnitId()`, `GetTargetRow()`, `GetTargetColumn()`
|
||||
- Impact: Eliminates 6 proto conversions per filtered command
|
||||
|
||||
2. **ShardokAIClient.cpp - GetAvailableCommandProtos calls**
|
||||
- Replace calls to `GetAvailableCommandProtos()` with `GetAvailableCommandsForAIPlayer()`
|
||||
- Impact: Eliminates conversion of entire command list
|
||||
|
||||
3. **IterativeDeepeningAI**
|
||||
- Change signature from `vector<CommandProto>` to `CommandListSPtr`
|
||||
- Impact: Main AI search algorithm avoids proto conversion
|
||||
|
||||
### MEDIUM Priority
|
||||
|
||||
4. **AIFleeDecisionCalculator**
|
||||
- Change to use `CommandListSPtr` and indices
|
||||
- Impact: Flee decision logic avoids proto
|
||||
|
||||
5. **ShardokAIClient strategy methods**
|
||||
- Update signatures to use `CommandListSPtr`
|
||||
- Cascades to strategy selectors
|
||||
|
||||
### LOW Priority
|
||||
|
||||
6. **Type aliases**
|
||||
- Remove unused `using CommandProto` declarations
|
||||
- Clean up imports
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Low-hanging fruit (AICommandFilter) - ✅ **COMPLETED** (PR #4505)
|
||||
- ✅ Replaced 6 proto conversions with direct accessor calls
|
||||
- ✅ Added exception handling for missing actor/target data
|
||||
- ✅ No signature changes needed
|
||||
- ✅ Immediate performance benefit
|
||||
- **PR:** #4505
|
||||
|
||||
### Phase 2: Entry point (ShardokAIClient)
|
||||
- Replace `GetAvailableCommandProtos()` calls with `GetAvailableCommandsForAIPlayer()`
|
||||
- Update method signatures in ShardokAIClient
|
||||
|
||||
### Phase 3: Core AI (IterativeDeepeningAI)
|
||||
- Change IterativeDeepeningAI to accept `CommandListSPtr`
|
||||
- This is the biggest change but has highest impact
|
||||
|
||||
### Phase 4: Supporting systems
|
||||
- Update AIFleeDecisionCalculator
|
||||
- Update strategy selectors
|
||||
- Clean up type aliases
|
||||
|
||||
### Phase 5: Validation code
|
||||
- Keep proto-based validation as-is (uses reflection)
|
||||
- Consider if validation is still needed in production
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **MCTS already converted**: The MCTS code path already uses `CommandListSPtr` directly
|
||||
- **Proto still needed**: For serialization/network communication (not in AI hot path)
|
||||
- **Validation**: Proto comparison in CheckCommand() should remain (uses proto reflection)
|
||||
|
||||
---
|
||||
|
||||
## Estimated Impact
|
||||
|
||||
**Proto conversions eliminated:** ~20-25 per command choice
|
||||
**Performance gain:** Eliminates hundreds of allocations per AI decision
|
||||
**Code simplification:** Removes proto conversion layer from AI
|
||||
|
||||
**Before:**
|
||||
```
|
||||
Command → Proto → AI Decision
|
||||
```
|
||||
|
||||
**After:**
|
||||
```
|
||||
Command → AI Decision (direct)
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,388 +0,0 @@
|
||||
# Deproto Migration Plan
|
||||
|
||||
## Vision
|
||||
|
||||
**Protocol buffers should only be used at the edges** — for network serialization (gRPC) and disk persistence. Inside the Eagle game engine, all logic should operate on native Scala models.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ GRPC BOUNDARY │
|
||||
│ EagleServiceImpl.scala ←→ Proto Messages ←→ Unity Client │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
GameStateConverter
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ SCALA ENGINE │
|
||||
│ │
|
||||
│ GameStateC ───→ Actions ───→ ActionResultT ───→ New GameStateC │
|
||||
│ ↑ │ │
|
||||
│ │ (Pure Scala models) │ │
|
||||
│ └───────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ HeroC, FactionC, ProvinceC, BattalionC, ArmyC, etc. │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
GameStateConverter
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ PERSISTENCE BOUNDARY │
|
||||
│ GameHistory.scala ←→ Proto Messages ←→ File/Database │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
### Completed Phases
|
||||
|
||||
| Phase | Status | Summary |
|
||||
|-------|--------|---------|
|
||||
| Phase 1: GameStateC | **Complete** | Scala `GameState` model with 22 fields |
|
||||
| Phase 2: EngineImpl | **Complete** | Holds Scala `GameState` internally |
|
||||
| Phase 3: GameHistory | **Complete** | `stateAfter` returns Scala GameState |
|
||||
| Phase 4: ActionResultT | **Complete** | All 59 actions return `ActionResultT` |
|
||||
| Phase 5: Action Base Classes | **Complete** | All `RandomSequentialResultsAction` and `DeterministicSingleResultAction` converted to T-type base classes |
|
||||
| Phase 5b: Base Class Cleanup | **Complete** | `RandomSequentialResultsAction` and `DeterministicSingleResultAction` deleted |
|
||||
| Phase 5c: RoundPhaseAdvancer Actions | **Complete** | All actions called by RoundPhaseAdvancer accept Scala GameState |
|
||||
| Phase 5d: RoundPhaseAdvancer Itself | **Complete** | RoundPhaseAdvancer.checkForPhaseAdvancement takes Scala GameState |
|
||||
|
||||
### Phase 5c/5d Progress (Complete)
|
||||
|
||||
`RoundPhaseAdvancer.checkForPhaseAdvancement` now accepts Scala `GameState` and `ActionResultApplier` directly (PR #4677).
|
||||
|
||||
| Action | PR | Status |
|
||||
|--------|-----|--------|
|
||||
| `PrisonerExchangeAction` | #4670 | ✅ Merged |
|
||||
| `PerformForcedTurnBackAction` | #4671 | ✅ Merged |
|
||||
| `PerformHeroDeparturesAction` | #4672 | ✅ Merged |
|
||||
| `RequestFreeForAllBattlesAction` | #4673 | ✅ Merged |
|
||||
| `EndPlayerCommandsPhaseAction` | #4674 | ✅ Merged |
|
||||
| `EndDiplomacyResolutionPhaseAction` | #4675 | ✅ Merged |
|
||||
| `RoundPhaseAdvancer` itself | #4677 | ✅ Merged |
|
||||
|
||||
### EngineImpl Progress
|
||||
|
||||
| Change | PR | Status |
|
||||
|--------|-----|--------|
|
||||
| `recursiveTransform` deleted | #4677 | ✅ Merged |
|
||||
| `recursiveTransformT` uses `RandomStateTSequencer` | #4677 | ✅ Merged |
|
||||
|
||||
### Current Architecture
|
||||
|
||||
**ActionResultT Production (100% Complete):**
|
||||
- All actions produce `ActionResultT`
|
||||
- Conversion to `ActionResultProto` happens via `ActionResultProtoConverter.toProto()`
|
||||
- No direct `ActionResultProto` construction outside the converter
|
||||
|
||||
**ActionResultProto Consumption (Next Target):**
|
||||
- `ActionResultProtoApplierImpl` - applies proto results to proto GameState
|
||||
- `RoundPhaseAdvancer` - calls converter, passes protos to applier
|
||||
- `InMemoryHistory` / `PersistedHistory` - stores proto results
|
||||
- Service layer (`GameController`, `GamesManager`, etc.) - uses proto for client communication
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Migrate to ActionResultT Consumers
|
||||
|
||||
### Objective
|
||||
|
||||
Eliminate internal consumption of `ActionResultProto`. Everything inside the engine should work with `ActionResultT`.
|
||||
|
||||
### Current Flow (Proto-Heavy)
|
||||
```
|
||||
Action.execute()
|
||||
→ ActionResultT
|
||||
→ ActionResultProtoConverter.toProto()
|
||||
→ ActionResultProto
|
||||
→ ActionResultProtoApplierImpl.applyActionResults()
|
||||
→ GameStateProto
|
||||
→ GameStateConverter.fromProto()
|
||||
→ GameStateC
|
||||
```
|
||||
|
||||
### Target Flow (T-Types Throughout)
|
||||
```
|
||||
Action.execute()
|
||||
→ ActionResultT
|
||||
→ ActionResultApplier.applyActionResults()
|
||||
→ GameStateC
|
||||
|
||||
(Proto conversion only at boundaries)
|
||||
```
|
||||
|
||||
### Key Files to Convert
|
||||
|
||||
**Tier 1 - Core Applier:** ✅ **Complete**
|
||||
```
|
||||
src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultApplierImpl.scala
|
||||
```
|
||||
`ActionResultApplier` applies `ActionResultT` directly to Scala `GameState`. The legacy `ActionResultTApplierImpl` wraps it and converts to/from proto for callers that still need proto types.
|
||||
|
||||
**Tier 2 - RoundPhaseAdvancer:** ✅ **Complete**
|
||||
```
|
||||
src/main/scala/net/eagle0/eagle/library/RoundPhaseAdvancer.scala
|
||||
```
|
||||
Now accepts Scala `GameState` and `ActionResultApplier`. Only converts to proto lazily for `AvailableCommandsFactory` calls.
|
||||
|
||||
**Tier 3 - Sequencers:**
|
||||
```
|
||||
src/main/scala/net/eagle0/eagle/library/actions/impl/common/RandomStateTSequencer.scala
|
||||
src/main/scala/net/eagle0/eagle/library/actions/impl/common/RandomStateProtoSequencer.scala
|
||||
```
|
||||
Modify `RandomStateTSequencer` to thread Scala `GameState` throughout (currently converts to proto internally). Then evaluate whether `RandomStateProtoSequencer` is still needed at all.
|
||||
|
||||
**Current State**: `RandomStateTSequencer` accepts Scala `GameState` via its `apply()` method but internally converts to proto. All callback methods (`withRandomActionResult`, `withActionResults`, etc.) pass `GameStateProto` to callers, forcing actions that use the sequencer to work with proto types internally.
|
||||
|
||||
**Target State**: Create a fully protoless sequencer where:
|
||||
1. `lastState` returns Scala `GameState` (not `lastStateProto`)
|
||||
2. All callback methods pass Scala `GameState` to callers
|
||||
3. Actions using the sequencer can be fully protoless
|
||||
|
||||
**Migration Path**:
|
||||
1. Add `lastState: GameState` method alongside `lastStateProto` (non-breaking)
|
||||
2. Add parallel callback methods that pass Scala GameState (e.g., `withScalaActionResult`)
|
||||
3. Migrate actions one by one to use the new Scala-based callbacks
|
||||
4. Once all actions migrated, deprecate/remove proto-based callbacks
|
||||
5. Remove `lastStateProto` once no longer used
|
||||
|
||||
**RandomStateSequencer Migration Progress** (PR #4679 introduced protoless `RandomStateSequencer`):
|
||||
|
||||
| Action | Status |
|
||||
|--------|--------|
|
||||
| `TruceTurnBackPhaseAction` | ✅ Migrated (PR #4680) |
|
||||
| `EndHandleRiotsPhaseAction` | ✅ Migrated (PR #4684) |
|
||||
| `PerformVassalCommandsPhaseAction` | ✅ Migrated |
|
||||
| `PerformVassalDefenseDecisionsAction` | ✅ Migrated |
|
||||
| `EndVassalCommandsPhaseAction` | ✅ Migrated |
|
||||
| `PerformReconResolutionAction` | ✅ Migrated |
|
||||
| `NewRoundAction` | ✅ Migrated (PR #4698) |
|
||||
| `EndBattleAftermathPhaseAction` | ✅ Migrated (PR #4699) |
|
||||
| `EndDiplomacyResolutionPhaseAction` | ✅ Migrated |
|
||||
| `PerformUnaffiliatedHeroesAction` | ✅ Migrated |
|
||||
| `EngineImpl.recursiveTransformT` | ✅ Migrated (PR #4704) |
|
||||
| `ProtolessSequentialResultsActionWrapper` | ✅ Migrated (PR #4705) |
|
||||
| `LegacyRandomStateTSequencer` | ✅ **Deleted** (PR #4705) |
|
||||
|
||||
**TCommandFactory Extraction** (PR #4684):
|
||||
|
||||
To enable lightweight mocking of command creation in tests, `TCommandFactory` trait was extracted from `CommandFactory`. This allows tests to mock just the `makeTCommand` method without pulling in all 40+ command dependencies that `CommandFactory` requires.
|
||||
|
||||
- `TCommandFactory` - lightweight trait with just `makeTCommand`
|
||||
- `CommandFactory extends TCommandFactory` - maintains backward compatibility
|
||||
- Actions accepting command factories now use `TCommandFactory` type for better testability
|
||||
|
||||
**Tier 4 - History APIs:**
|
||||
```
|
||||
src/main/scala/net/eagle0/eagle/service/InMemoryHistory.scala
|
||||
src/main/scala/net/eagle0/eagle/service/PersistedHistory.scala
|
||||
```
|
||||
Change APIs to vend Scala `GameState` and `ActionResultT` instead of proto versions. `PersistedHistory` converts to proto internally for disk persistence; `InMemoryHistory` doesn't need proto at all.
|
||||
|
||||
### ActionResultProto Consumer Inventory
|
||||
|
||||
| File | Usage | Status |
|
||||
|------|-------|--------|
|
||||
| `ActionResultApplierImpl.scala` | Applies ActionResultT to Scala GameState | ✅ **Complete** |
|
||||
| `ActionResultTApplierImpl.scala` | Legacy wrapper - converts to/from proto | Keep until all callers migrated |
|
||||
| `RoundPhaseAdvancer.scala` | Uses Scala GameState | ✅ **Complete** |
|
||||
| `RandomStateSequencer.scala` | Threads Scala GameState | ✅ **Complete** |
|
||||
| `VigorXPApplier.scala` | Has both proto and Scala methods | Scala method exists, delete proto method when unused |
|
||||
| `PerformForcedTurnBackAction.scala` | Fully protoless | ✅ **Complete** |
|
||||
| `ResolveBattleAction.scala` | ✅ Fully protoless | Complete (PR #5048) |
|
||||
| `InMemoryHistory.scala` | Stores proto results | Pending - vend Scala types |
|
||||
| `PersistedHistory.scala` | Stores proto results | Pending - vend Scala types, convert for disk |
|
||||
| `GameController.scala` | Uses proto for client communication | Keep proto (gRPC boundary) |
|
||||
|
||||
### Remaining Proto Usage in Actions
|
||||
|
||||
**Progress: 52 of 52 action files (100%) are fully protoless.** ✅
|
||||
|
||||
All action files have been migrated to use Scala types:
|
||||
|
||||
| Action | Status | Notes |
|
||||
|--------|--------|-------|
|
||||
| `ResolveBattleAction` | ✅ Complete | PR #5048 - uses Scala GameState and ActionResultApplier |
|
||||
| `PerformVassalCommandsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
|
||||
| `EndHandleRiotsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
|
||||
| `PerformVassalDefenseDecisionsAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
|
||||
| `EndVassalCommandsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
|
||||
|
||||
**Note:** `NewRoundAction` is now fully protoless after converting `ChronicleEventGenerator` to return Scala `ChronicleEvent` types directly.
|
||||
|
||||
**Deleted Dead Code:**
|
||||
- `UnaffiliatedHeroMovedAction` - Was never called; `PerformUnaffiliatedHeroesAction.heroMovedResult` constructs `ActionResultC` directly
|
||||
- `HeroBackstoryUpdateActionGenerator.fromGameState` - Dead method that converted proto to Scala; only `apply(GameState)` is used
|
||||
|
||||
**Note**: `PerformReconResolutionAction` and `EndBattleAftermathPhaseAction` are now fully protoless after:
|
||||
1. Migrating `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` to use Scala `ProvinceView`
|
||||
2. Adding Scala overload of `ProvinceViewFilter.withdrawnFromProvinceView`
|
||||
|
||||
### Estimated Effort (Remaining)
|
||||
|
||||
| Component | Lines | Complexity | Blocks |
|
||||
|-----------|-------|------------|--------|
|
||||
| History API updates | ~100 | Low | - |
|
||||
| **Total Remaining** | **~100** | | |
|
||||
|
||||
**Completed:**
|
||||
- ✅ `ChronicleEventGenerator` converted to return Scala `ChronicleEvent` types directly
|
||||
- ✅ `CommandChoiceHelpers` migrated to Scala types
|
||||
- ✅ `ResolveBattleAction` refactored to use Scala GameState and ActionResultApplier (PR #5048)
|
||||
|
||||
### Enum Type Migrations
|
||||
|
||||
Proto enums are being converted to Scala sealed traits with converters at boundaries:
|
||||
|
||||
| Enum | Scala Type | Status | Notes |
|
||||
|------|------------|--------|-------|
|
||||
| `DiplomacyOfferStatus` | `Status` sealed trait | ✅ **Complete** | PR #5093 - `EligibleDiplomacyStatuses` uses Scala types internally |
|
||||
| `RoundPhase` | `RoundPhase` sealed trait | Partial | Some usages converted |
|
||||
| `BattalionType` | `BattalionType` sealed trait | Partial | Some usages converted |
|
||||
|
||||
**DiplomacyOfferStatus Migration (PR #5093):**
|
||||
- `EligibleDiplomacyStatuses.scala` now returns `Vector[Status]` instead of `Vector[DiplomacyOfferStatus]`
|
||||
- Call sites in `AvailableResolve*CommandFactory` files convert to proto via `StatusConverter.toProto` at the boundary
|
||||
- This pattern should be applied to other proto enums
|
||||
|
||||
### CommandChoiceHelpers Migration Status
|
||||
|
||||
Several command selectors have already been converted to use Scala types:
|
||||
|
||||
| File | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| `AttackCommandChooser.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
|
||||
| `AlmsCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
|
||||
| `FoodConsumptionUtils.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `RoundPhase` |
|
||||
| `MarchSuppliesHelpers.scala` | ✅ **Protoless** | Uses `BattalionT` |
|
||||
| `CombatUnitSelector.scala` | ✅ **Protoless** | Uses `HeroT`, `BattalionT`, `BattalionType` |
|
||||
| `ExpandCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `FactionT` |
|
||||
| `ImproveCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `HeroT` |
|
||||
| `OrganizeCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `BattalionT`, `BattalionType` |
|
||||
| `RansomOfferHelpers.scala` | ✅ **Protoless** | Uses Scala `GameState`, `FactionT` |
|
||||
| `CommandChoiceHelpers.scala` | ✅ **Protoless** | Uses Scala `GameState` throughout |
|
||||
| `ProvinceGoldSurplusCalculator.scala` | ✅ **Protoless** | Uses Scala types |
|
||||
|
||||
**All CommandChoiceHelpers selectors have been migrated to Scala types.** ✅
|
||||
|
||||
### Progress Summary
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Action files fully protoless | 52 / 52 (100%) ✅ |
|
||||
| Proto usages in remaining actions | 0 |
|
||||
| Next target | See "Next Candidates" section below |
|
||||
|
||||
### Next Candidates
|
||||
|
||||
Priority candidates for further deproto work:
|
||||
|
||||
1. **More Enum Migrations** - Apply the `DiplomacyOfferStatus` pattern to other proto enums:
|
||||
- Files importing `net.eagle0.eagle.common.round_phase.RoundPhase` (proto) could use Scala `RoundPhase`
|
||||
- Files importing `net.eagle0.eagle.common.battalion_type.BattalionType` (proto) could use Scala `BattalionType`
|
||||
|
||||
2. **AvailableCommandsFactory Files** - Many still use proto `GameState` internally:
|
||||
- These files build proto `AvailableCommand` messages but could use Scala types for internal logic
|
||||
- Convert to accept Scala `GameState`, only convert fields to proto when building the response
|
||||
|
||||
3. **History APIs** - `InMemoryHistory` and `PersistedHistory`:
|
||||
- Change to vend Scala `GameState` and `ActionResultT` instead of proto versions
|
||||
- `PersistedHistory` converts to proto internally for disk persistence
|
||||
|
||||
### Validation
|
||||
- [x] `ActionResultApplier` created and tested
|
||||
- [x] `RandomStateSequencer` threads Scala GameState throughout
|
||||
- [x] `RoundPhaseAdvancer` uses T-types internally
|
||||
- [x] `ProvinceViewFilter` has Scala overload for server-side use (PR #4752)
|
||||
- [x] `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` use Scala `ProvinceView`
|
||||
- [x] `ProvinceViewFilter.withdrawnFromProvinceView` has Scala overload
|
||||
- [x] `CommandChoiceHelpers` uses Scala types ✅
|
||||
- [x] All action files (52/52) are fully protoless ✅
|
||||
- [ ] `ProvinceViewFilter` faction-filtered views use Scala types
|
||||
- [ ] History APIs vend Scala types
|
||||
- [ ] No `ActionResultProtoConverter.toProto()` calls except at persistence/gRPC boundaries
|
||||
- [ ] All tests pass
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Clean Up Legacy Utilities
|
||||
|
||||
### Objective
|
||||
Remove remaining direct proto imports from utility classes.
|
||||
|
||||
### Files to Modify
|
||||
|
||||
| File | Status |
|
||||
|------|--------|
|
||||
| `CommandChoiceHelpers.scala` | Accepts proto `GameState`; blocks full deproto of `PerformVassalCommandsPhaseAction` and `PerformVassalDefenseDecisionsAction` |
|
||||
| `LegacyProvinceUtils.scala` | Replace with `ProvinceUtils.scala` - `hasImminentRiot` added (PR #4683) |
|
||||
| `LegacyFactionUtils.scala` | Replace proto imports with `FactionT` |
|
||||
| `LegacyUnaffiliatedHeroUtils.scala` | Replace proto imports with Scala models |
|
||||
| `BattalionTypeLoader.scala` | Keep proto for file loading, convert immediately after |
|
||||
| `BeastUtils.scala` | **Complete** - now uses Scala `BeastInfo` only |
|
||||
|
||||
### View Filters (Partially Complete)
|
||||
|
||||
The view filter utilities now have Scala overloads for server-side use:
|
||||
|
||||
| File | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| `ProvinceViewFilter.scala` | **Partial** | `filteredProvinceView(ProvinceT, ScalaGameState)` added (PR #4752) |
|
||||
| `ArmyFilter.scala` | **Partial** | `filterArmy(ScalaArmy, Map[BattalionId, BattalionT], Option[FactionId])` added |
|
||||
| `BattalionViewFilter.scala` | **Complete** | Uses Scala `BattalionT` throughout |
|
||||
| `GameStateViewFilter.scala` | Pending | Uses proto types throughout |
|
||||
| `GameStateViewDiffer.scala` | Pending | Works with view protos |
|
||||
|
||||
**Unblocked Actions** (PR #4752):
|
||||
- `EndBattleAftermathPhaseAction` - can now use `filteredProvinceView(province, scalaGameState)`
|
||||
- `PerformReconResolutionAction` - can now use Scala overload
|
||||
- `GameStateFactionExtensions` - can now use `updatedReconnedProvinces` with Scala types
|
||||
|
||||
**Remaining Work**:
|
||||
- Faction-filtered `filteredProvinceView(Province, GameState, FactionId)` still uses proto types
|
||||
- `withdrawnFromProvinceView` still uses proto types
|
||||
- These are needed for client-facing views with visibility restrictions
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Verify Boundaries
|
||||
|
||||
### Objective
|
||||
Confirm protos are used correctly at boundaries — and ONLY there.
|
||||
|
||||
### Expected Proto Usage (Keep)
|
||||
- `EagleServiceImpl.scala` - gRPC boundary
|
||||
- `InMemoryHistory.scala` / `PersistedHistory.scala` - Persistence boundary
|
||||
- `*Converter.scala` - Explicit conversion utilities
|
||||
- `*Loader.scala` - File loading utilities
|
||||
|
||||
### Expected No Proto Usage (Verify)
|
||||
- `/library/actions/impl/` - Pure Scala models
|
||||
- `/library/util/` - Pure Scala models (except loaders)
|
||||
- `/model/state/` - Pure Scala models
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Persistence Format**: Currently game state is persisted as proto. Should we keep proto for persistence (good for schema evolution) or switch to a different format?
|
||||
|
||||
2. **Shardok Integration**: `ResolveBattleAction` communicates with Shardok. Should the Shardok interface use protos (external service) or Scala models?
|
||||
|
||||
3. **View Generation**: `GameStateViewDiffer` works with view protos for client updates. Views need Scala models (`ProvinceViewT`, etc.) to allow actions like `EndBattleAftermathPhaseAction` to be fully protoless. The Scala views would be converted to proto only at the gRPC boundary when sending updates to clients.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Code Quality
|
||||
- [ ] Zero proto imports in `/library/actions/` (except boundaries)
|
||||
- [ ] Zero proto imports in `/library/` utilities (except loaders)
|
||||
- [ ] `GameStateT` used throughout engine internals
|
||||
- [ ] Proto usage limited to: `EagleServiceImpl`, loaders, converters, persistence
|
||||
|
||||
### Architecture
|
||||
- [ ] Clear separation: Scala models (internal) vs Proto (boundaries)
|
||||
- [ ] Converters as the only bridge between domains
|
||||
- [ ] No "proto creep" into business logic
|
||||
@@ -1,159 +0,0 @@
|
||||
# Hetzner Setup Guide
|
||||
|
||||
This guide walks through setting up Hetzner Cloud infrastructure for running Shardok on-demand compute.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- All code PRs merged (#4990, #4996, #4998, #5001, #5009)
|
||||
- Access to DigitalOcean Container Registry (for pulling Shardok ARM64 image)
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create Hetzner Cloud Account
|
||||
|
||||
1. Go to https://console.hetzner.cloud/
|
||||
2. Sign up and add payment method
|
||||
3. Create a new project (e.g., "eagle0")
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Generate Hetzner API Token
|
||||
|
||||
1. In Hetzner Console → Security → API Tokens
|
||||
2. Click "Generate API Token"
|
||||
3. Give it **Read & Write** permissions
|
||||
4. Copy the token (you'll only see it once)
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Generate Shardok Auth Token
|
||||
|
||||
Generate a 256-bit random token for Eagle-Shardok authentication:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Save this output - it's the shared secret between Eagle and Shardok.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Store Secrets in GitHub Actions
|
||||
|
||||
Add these secrets in GitHub → Settings → Secrets and variables → Actions:
|
||||
|
||||
| Secret Name | Description |
|
||||
|-------------|-------------|
|
||||
| `HETZNER_API_TOKEN` | From Step 2 - for Hetzner API calls |
|
||||
| `SHARDOK_AUTH_TOKEN` | From Step 3 - shared secret for gRPC auth |
|
||||
|
||||
Note: `DO_REGISTRY_TOKEN` already exists and will be used for Hetzner to pull container images.
|
||||
|
||||
These secrets will be passed to Eagle at runtime via `docker_build.yml`, similar to how `OPENAI_API_KEY` and other secrets are handled.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: DNS Setup (for Let's Encrypt)
|
||||
|
||||
You need a domain pointing to the Shardok instance for TLS certificates.
|
||||
|
||||
### Option A: Floating IP (Recommended)
|
||||
|
||||
1. In Hetzner Console → Networking → Floating IPs
|
||||
2. Create a **Floating IPv6** in **Hillsboro, Oregon (hil)** region
|
||||
- IPv6 costs €1/month vs €3/month for IPv4
|
||||
- Hillsboro has better latency to DigitalOcean SFO than Ashburn
|
||||
- Server-to-server communication works fine with IPv6-only
|
||||
3. Point `shardok.prod.eagle0.net` to this IP via AAAA record
|
||||
4. The ShardokInstanceManager will attach this IP to instances on spin-up
|
||||
|
||||
**Location choice**: Hillsboro, OR (`hil`) is recommended for US West Coast. Same pricing as Ashburn (`ash`).
|
||||
|
||||
### Option B: Dynamic DNS
|
||||
|
||||
Update DNS programmatically when instance spins up. More complex but avoids floating IP cost.
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Upload SSH Key to Hetzner
|
||||
|
||||
For debugging access to instances:
|
||||
|
||||
1. In Hetzner Console → Security → SSH Keys
|
||||
2. Click "Add SSH Key"
|
||||
3. Paste your public key (e.g., `~/.ssh/id_rsa.pub`)
|
||||
4. Give it a name (e.g., "eagle-deploy")
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Wire Security Config into Eagle
|
||||
|
||||
Update Eagle's startup code to use the security config when connecting to remote Shardok:
|
||||
|
||||
```scala
|
||||
val securityConfig = ShardokSecurityConfig(
|
||||
useTls = true,
|
||||
authToken = Some(sys.env("SHARDOK_AUTH_TOKEN"))
|
||||
)
|
||||
|
||||
val channel = ServerSetupHelpers.newChannel(
|
||||
"shardok.prod.eagle0.net",
|
||||
50051,
|
||||
securityConfig
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Instance Spin-up
|
||||
|
||||
Test the Hetzner integration by triggering instance creation:
|
||||
|
||||
```scala
|
||||
val manager = new ShardokInstanceManager(
|
||||
hetznerApiToken = sys.env("HETZNER_API_TOKEN"),
|
||||
// ... other config
|
||||
)
|
||||
|
||||
manager.ensureInstanceRunning()
|
||||
```
|
||||
|
||||
### Verify TLS and Auth
|
||||
|
||||
1. Instance spins up and gets Let's Encrypt certificate
|
||||
2. Eagle connects via TLS
|
||||
3. Auth token is validated on each request
|
||||
|
||||
---
|
||||
|
||||
## Cost Estimate
|
||||
|
||||
| Component | Cost |
|
||||
|-----------|------|
|
||||
| CAX41 (16 ARM cores) | ~$0.04/hour |
|
||||
| Floating IP | ~$4/month |
|
||||
| Typical usage (20 hrs/week) | ~$3.50/month compute |
|
||||
|
||||
**Total: ~$7-8/month** for typical usage.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Instance won't start
|
||||
- Check Hetzner API token has Read & Write permissions
|
||||
- Verify you're using the correct region (`hil` for Hillsboro OR, or `ash` for Ashburn VA)
|
||||
|
||||
### TLS certificate fails
|
||||
- Ensure DNS points to the instance IP before certbot runs
|
||||
- Check port 80 is open for Let's Encrypt HTTP-01 challenge
|
||||
|
||||
### Auth failures
|
||||
- Verify `SHARDOK_AUTH_TOKEN` matches on both Eagle and Shardok
|
||||
- Check the token file is readable by Shardok container
|
||||
|
||||
### Can't pull container image
|
||||
- Ensure `DO_REGISTRY_TOKEN` is passed to cloud-init
|
||||
- Verify the ARM64 image exists: `registry.digitalocean.com/eagle0/shardok-server:arm64-latest`
|
||||
@@ -1,383 +0,0 @@
|
||||
# Plan: Extract OAuth to Go Service
|
||||
|
||||
## Goal
|
||||
Move OAuth authentication handling from the Eagle Scala server into a separate Go service. This simplifies Eagle (gRPC-only, no HTTP), reduces complexity, and sets up for potentially moving JWT validation outside Eagle too.
|
||||
|
||||
## Architecture Decision: Sidecar Service (Not DO Functions)
|
||||
|
||||
**Recommendation: Go sidecar service on the same droplet, in a separate container**
|
||||
|
||||
**Why not DO Functions:**
|
||||
- OAuth requires **stateful sessions** (pendingOAuth/completedOAuth maps with 10-min TTL)
|
||||
- Client polling pattern (every 2 seconds) would incur high function invocation costs
|
||||
- Cold start latency problematic for auth flows
|
||||
- State would require external store (Redis), adding complexity
|
||||
|
||||
**Why sidecar (separate container):**
|
||||
- Simple process on same droplet, minimal network latency
|
||||
- In-memory state management (like current Scala impl)
|
||||
- Easy to monitor/debug alongside Eagle
|
||||
- Can share filesystem for key files (RSA keys) via volume mounts
|
||||
- **Independent deployment**: Deploying Eagle doesn't restart auth service (and vice versa)
|
||||
- **Independent scaling**: Could move to separate droplet later if needed
|
||||
|
||||
## Current Architecture (What Exists)
|
||||
|
||||
```
|
||||
Unity Client
|
||||
├── GetOAuthUrl RPC → Eagle AuthServiceImpl → OAuthService.getAuthUrl()
|
||||
├── [User browser auth] → HTTP callback → OAuthHttpHandler → OAuthService.handleCallback()
|
||||
├── CheckOAuthStatus RPC (polling) → AuthServiceImpl → OAuthService.checkStatus()
|
||||
└── All other RPCs include JWT → AuthorizationInterceptor validates
|
||||
```
|
||||
|
||||
**Key files:**
|
||||
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth flow, state management
|
||||
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - JWT creation/validation
|
||||
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD (persisted)
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC Auth service
|
||||
- `src/main/scala/net/eagle0/eagle/service/OAuthHttpHandler.scala` - HTTP callback handler
|
||||
|
||||
## Target Architecture (Phase 1)
|
||||
|
||||
```
|
||||
Unity Client
|
||||
├── GetOAuthUrl RPC ──────────────┐
|
||||
├── CheckOAuthStatus RPC (polling)├──→ Eagle (port 40032) ──proxy──→ Go Auth Container (port 40033)
|
||||
├── RefreshToken RPC ─────────────┘ │
|
||||
├── [User browser] → HTTP callback ────────────────────────────────────────┤
|
||||
│ ↓
|
||||
│ (Internal gRPC: GetOrCreateUser, GetUser)
|
||||
│ ↓
|
||||
└── Game RPCs with JWT ─────────────────────→ Eagle (port 40032) ← JWT validation stays here
|
||||
|
||||
[Same Droplet]
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌──────────────────────┐ ┌────────────────────────────────────┐ │
|
||||
│ │ Go Auth Container │◄────────►│ Eagle Container │ │
|
||||
│ │ (eagle0-auth) │ internal │ (eagle0-server) │ │
|
||||
│ │ │ gRPC │ │ │
|
||||
│ │ - OAuth flow │ │ - JWT validation │ │
|
||||
│ │ - JWT creation │ │ - UserService (persistence) │ │
|
||||
│ │ - HTTP callback │ │ - Game logic │ │
|
||||
│ └──────────────────────┘ └────────────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ └────────────────┬───────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ /etc/eagle0/keys/ (shared volume) │
|
||||
│ - private.pem │
|
||||
│ - public.pem │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Component Responsibilities
|
||||
|
||||
### Go Auth Service (NEW - separate container)
|
||||
- **OAuth flow**: getAuthUrl, handleCallback (HTTP), checkStatus
|
||||
- **State management**: pendingOAuth, completedOAuth maps with TTL
|
||||
- **JWT creation**: Issue access/refresh tokens (shares RSA private key with Eagle)
|
||||
- **Token refresh**: Validate refresh token, issue new access token
|
||||
- Calls Eagle's internal UserService gRPC to find/create users
|
||||
|
||||
### Eagle Server (SIMPLIFIED)
|
||||
- **JWT validation**: AuthorizationInterceptor stays (validates tokens on game RPCs)
|
||||
- **UserService**: Stays in Eagle (user persistence, display name logic)
|
||||
- **New internal gRPC**: Expose GetOrCreateUser, GetUser for Go service to call
|
||||
- **Proxy (Phase 1)**: Forward OAuth RPCs to Go service
|
||||
- **Remove (Phase 2)**: OAuthService, OAuthHttpHandler, HTTP server setup
|
||||
|
||||
### Unity Client (NO CHANGES in Phase 1)
|
||||
- Eagle proxies Auth RPCs to Go service
|
||||
- Client still connects to Eagle on port 40032
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Go Auth Service with Eagle Proxy (Zero Client Changes)
|
||||
|
||||
1. **Create Go service structure**
|
||||
```
|
||||
src/main/go/net/eagle0/authservice/
|
||||
├── main.go # Entry point, starts gRPC + HTTP servers
|
||||
├── oauth.go # OAuth state management, provider configs
|
||||
├── jwt.go # JWT creation (copy logic from Scala)
|
||||
├── handlers.go # gRPC handlers for Auth service
|
||||
├── http_callback.go # HTTP handler for OAuth callback
|
||||
└── BUILD.bazel
|
||||
```
|
||||
|
||||
2. **Internal gRPC proto for Eagle UserService**
|
||||
```protobuf
|
||||
// src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto
|
||||
|
||||
service InternalUserService {
|
||||
rpc GetOrCreateUser(GetOrCreateUserRequest) returns (GetOrCreateUserResponse);
|
||||
rpc GetUser(GetUserRequest) returns (GetUserResponse);
|
||||
}
|
||||
|
||||
message GetOrCreateUserRequest {
|
||||
string provider = 1; // "discord" or "google"
|
||||
string provider_user_id = 2;
|
||||
string email = 3;
|
||||
string avatar_url = 4;
|
||||
}
|
||||
|
||||
message GetOrCreateUserResponse {
|
||||
string user_id = 1;
|
||||
string display_name = 2;
|
||||
string avatar_url = 3;
|
||||
bool is_admin = 4;
|
||||
bool is_new_user = 5;
|
||||
}
|
||||
```
|
||||
|
||||
3. **Eagle: Expose InternalUserService**
|
||||
- New `InternalUserServiceImpl.scala` wrapping UserService
|
||||
- Bind to same port, different service name (internal only)
|
||||
|
||||
4. **Eagle: Proxy Auth RPCs to Go**
|
||||
- AuthServiceImpl delegates GetOAuthUrl, CheckOAuthStatus, RefreshToken to Go service
|
||||
- SetDisplayName, GetCurrentUser, Logout stay in Eagle
|
||||
|
||||
5. **Share RSA keys via volume mount**
|
||||
- Go service reads same key files as Eagle
|
||||
- Both can create valid JWTs
|
||||
- Eagle continues to validate JWTs
|
||||
|
||||
6. **Docker/Container setup**
|
||||
- New Dockerfile for Go auth service
|
||||
- docker-compose or Kubernetes config for both containers
|
||||
- Shared volume for /etc/eagle0/keys/
|
||||
- Internal network for container-to-container gRPC
|
||||
|
||||
### Phase 2: Client Direct to Go Service (Future)
|
||||
|
||||
1. **Update Unity client**
|
||||
- Connect to Go Auth service directly for OAuth RPCs
|
||||
- Keep connecting to Eagle for game RPCs
|
||||
|
||||
2. **Remove Eagle proxy code**
|
||||
- Delete AuthServiceImpl OAuth delegation
|
||||
- AuthServiceImpl only handles SetDisplayName, GetCurrentUser, Logout
|
||||
|
||||
### Phase 3: Move JWT Validation to Go (Optional Future)
|
||||
|
||||
1. **Go service validates JWTs**
|
||||
- Add ValidateToken RPC or use shared middleware pattern
|
||||
|
||||
2. **Eagle calls Go for validation**
|
||||
- AuthorizationInterceptor calls Go to validate tokens
|
||||
- OR: Use stateless validation (both share public key)
|
||||
|
||||
## Files to Create
|
||||
|
||||
### Go Service
|
||||
- `src/main/go/net/eagle0/authservice/main.go`
|
||||
- `src/main/go/net/eagle0/authservice/oauth.go`
|
||||
- `src/main/go/net/eagle0/authservice/jwt.go`
|
||||
- `src/main/go/net/eagle0/authservice/handlers.go`
|
||||
- `src/main/go/net/eagle0/authservice/http_callback.go`
|
||||
- `src/main/go/net/eagle0/authservice/BUILD.bazel`
|
||||
|
||||
### Protos
|
||||
- `src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto`
|
||||
|
||||
### Scala
|
||||
- `src/main/scala/net/eagle0/eagle/service/InternalUserServiceImpl.scala`
|
||||
|
||||
### Docker/Deployment
|
||||
- `ci/auth_service.Dockerfile`
|
||||
- Update `docker-compose.yml` (or equivalent)
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### Scala (Phase 1)
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - Proxy OAuth RPCs to Go
|
||||
- `src/main/scala/net/eagle0/eagle/Main.scala` - Start internal user service, add auth-service-url flag
|
||||
|
||||
### Scala (Phase 2 - Removal)
|
||||
- Delete `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala`
|
||||
- Delete `src/main/scala/net/eagle0/eagle/service/OAuthHttpHandler.scala`
|
||||
- Simplify `src/main/scala/net/eagle0/eagle/Main.scala` - Remove HTTP server
|
||||
|
||||
### Unity (Phase 2)
|
||||
- `Assets/Auth/OAuthManager.cs` - Point OAuth RPCs to Go service port
|
||||
- `Assets/EagleConnection.cs` - Add second channel for auth service
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
### State Management in Go
|
||||
```go
|
||||
type OAuthState struct {
|
||||
Provider string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type OAuthResult struct {
|
||||
Success bool
|
||||
UserInfo *ProviderUserInfo
|
||||
Provider string
|
||||
Error string
|
||||
}
|
||||
|
||||
var pendingOAuth = sync.Map{} // state -> OAuthState
|
||||
var completedOAuth = sync.Map{} // state -> OAuthResult
|
||||
|
||||
const stateExpiration = 10 * time.Minute
|
||||
|
||||
// Background goroutine cleans expired states every minute
|
||||
func cleanupExpiredStates() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
for range ticker.C {
|
||||
cutoff := time.Now().Add(-stateExpiration)
|
||||
pendingOAuth.Range(func(key, value any) bool {
|
||||
if value.(OAuthState).CreatedAt.Before(cutoff) {
|
||||
pendingOAuth.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
// Similar for completedOAuth
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### JWT Creation in Go
|
||||
```go
|
||||
import "github.com/golang-jwt/jwt/v5"
|
||||
|
||||
type EagleClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
UserId string `json:"userId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
}
|
||||
|
||||
func CreateAccessToken(userId, displayName string, isAdmin bool) (string, error) {
|
||||
claims := EagleClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
UserId: userId,
|
||||
DisplayName: displayName,
|
||||
IsAdmin: isAdmin,
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
return token.SignedString(privateKey)
|
||||
}
|
||||
```
|
||||
|
||||
### OAuth Provider Configs
|
||||
- Read from environment variables (same as current OAuthConfig.scala)
|
||||
- DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET
|
||||
- GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
|
||||
- OAUTH_CALLBACK_URL (e.g., https://eagle0.shardok.games/oauth/callback)
|
||||
|
||||
### Container Networking
|
||||
```yaml
|
||||
# docker-compose.yml example
|
||||
services:
|
||||
eagle0-auth:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ci/auth_service.Dockerfile
|
||||
ports:
|
||||
- "40033:40033" # gRPC
|
||||
- "8080:8080" # HTTP callback
|
||||
volumes:
|
||||
- ./keys:/etc/eagle0/keys:ro
|
||||
environment:
|
||||
- DISCORD_CLIENT_ID
|
||||
- DISCORD_CLIENT_SECRET
|
||||
- GOOGLE_CLIENT_ID
|
||||
- GOOGLE_CLIENT_SECRET
|
||||
- EAGLE_INTERNAL_URL=eagle0-server:40034
|
||||
|
||||
eagle0-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ci/eagle_run.Dockerfile
|
||||
ports:
|
||||
- "40032:40032" # Public gRPC
|
||||
expose:
|
||||
- "40034" # Internal gRPC (container-to-container only)
|
||||
volumes:
|
||||
- ./keys:/etc/eagle0/keys:ro
|
||||
- ./data:/var/lib/eagle0
|
||||
environment:
|
||||
- AUTH_SERVICE_URL=eagle0-auth:40033
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Development
|
||||
```bash
|
||||
# Terminal 1: Go Auth Service
|
||||
bazel run //src/main/go/net/eagle0/authservice:authservice -- \
|
||||
--grpc-port=40033 \
|
||||
--http-port=8080 \
|
||||
--eagle-internal-url=localhost:40034
|
||||
|
||||
# Terminal 2: Eagle Server
|
||||
bazel run //src/main/scala/net/eagle0/eagle:eagle_server -- \
|
||||
--eagle-grpc-port=40032 \
|
||||
--internal-grpc-port=40034 \
|
||||
--auth-service-url=localhost:40033
|
||||
```
|
||||
|
||||
### Production
|
||||
- Both containers on same droplet via docker-compose
|
||||
- Shared volume for RSA keys at /etc/eagle0/keys/
|
||||
- Internal Docker network for container-to-container communication
|
||||
- External access: 40032 (Eagle gRPC), 8080 (OAuth HTTP callback)
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit tests for Go service**
|
||||
- OAuth state management (expiration, cleanup)
|
||||
- JWT creation matches Scala output (test with same keys)
|
||||
- HTTP callback parsing
|
||||
|
||||
2. **Integration tests**
|
||||
- Go service ↔ Eagle internal gRPC
|
||||
- Full OAuth flow with mock provider
|
||||
|
||||
3. **Existing tests continue to pass**
|
||||
- All Scala tests (JWT validation, user service)
|
||||
|
||||
4. **End-to-end test**
|
||||
- Spin up both containers
|
||||
- Run OAuth flow through proxy
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Key file permissions | Shared volume with read-only mount |
|
||||
| State loss on Go restart | Document this (same as current Scala behavior); consider Redis later |
|
||||
| Clock skew affecting JWT | Both on same machine |
|
||||
| OAuth callback race | HTTP callback completes before gRPC poll |
|
||||
| Container networking | Use docker-compose for reliable internal DNS |
|
||||
| Proxy adds latency | Minimal (same machine), remove in Phase 2 |
|
||||
|
||||
## Estimated Scope
|
||||
|
||||
- **Phase 1**: ~500-700 lines Go, ~100 lines Scala changes, ~50 lines Docker config
|
||||
- **Phase 2**: ~50 lines Unity, deletion of ~300 lines Scala
|
||||
- **Phase 3**: Optional, separate decision
|
||||
|
||||
## Alternative Considered: Move Everything to Go
|
||||
|
||||
Could move UserService to Go as well, but:
|
||||
- UserService is tightly integrated with game persistence
|
||||
- Would require duplicating persistence layer
|
||||
- Not worth the complexity for now
|
||||
|
||||
Keep UserService in Eagle, expose via internal gRPC.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **HTTP callback routing**: Does the OAuth callback URL need to change, or can we route traffic from the existing URL to the new Go service?
|
||||
2. **Health checks**: Should we add health check endpoints for container orchestration?
|
||||
3. **Logging**: Should Go service log to same format/destination as Eagle?
|
||||
@@ -1,189 +0,0 @@
|
||||
# Discord + Google OAuth Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Replace HTTP Basic Auth with OAuth 2.0 (Discord + Google) for Eagle0. Users authenticate via system browser, receive JWT tokens, and choose their own display names.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Unity Client Eagle Server
|
||||
| |
|
||||
| 1. Click "Login with Discord/Google" |
|
||||
| -------------------------------------------------> |
|
||||
| GetOAuthUrl(provider) -> auth_url + state |
|
||||
| |
|
||||
| 2. Open system browser -> OAuth consent |
|
||||
| 3. User authenticates with provider |
|
||||
| 4. Redirect to eagle0://auth/callback?code=xxx |
|
||||
| |
|
||||
| 5. ExchangeCode(code, state) |
|
||||
| -------------------------------------------------> |
|
||||
| Exchange code with provider |
|
||||
| Fetch user info (id, email, avatar) |
|
||||
| Create/update user record |
|
||||
| Issue JWT + refresh token |
|
||||
| <------------------------------------------------- |
|
||||
| (jwt, refresh_token, user_info, is_new_user) |
|
||||
| |
|
||||
| 6. [If new user] SetDisplayName(name) |
|
||||
| -------------------------------------------------> |
|
||||
| |
|
||||
| 7. Subsequent gRPC calls |
|
||||
| Authorization: Bearer <jwt> |
|
||||
| -------------------------------------------------> |
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| OAuth flow | System browser + deep link | Secure, supports password managers |
|
||||
| Code exchange | Eagle server directly | No separate auth service needed |
|
||||
| JWT signing | RS256 (asymmetric) | Future flexibility for token verification |
|
||||
| User storage | Protobuf file via Persister | Consistent with existing patterns |
|
||||
| Token expiry | 7-day access, 30-day refresh | Balance security and gaming UX |
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Proto Definitions & Infrastructure
|
||||
|
||||
**New files:**
|
||||
- `src/main/protobuf/net/eagle0/eagle/api/auth.proto` - Auth API messages
|
||||
- `src/main/protobuf/net/eagle0/eagle/internal/user.proto` - User storage schema
|
||||
|
||||
**Key proto messages:**
|
||||
```protobuf
|
||||
// API
|
||||
GetOAuthUrlRequest/Response // Get OAuth URL to open in browser
|
||||
ExchangeCodeRequest/Response // Exchange auth code for JWT
|
||||
SetDisplayNameRequest/Response // Set user's display name
|
||||
RefreshTokenRequest/Response // Refresh expired access token
|
||||
|
||||
// Internal storage
|
||||
User // user_id, display_name, oauth_identities
|
||||
UserDatabase // All users + indexes for lookup
|
||||
```
|
||||
|
||||
### Phase 2: Eagle Server Auth Services
|
||||
|
||||
**New Scala files:**
|
||||
- `src/main/scala/net/eagle0/eagle/auth/OAuthConfig.scala` - Discord/Google config from env vars
|
||||
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - JWT creation/validation (RS256)
|
||||
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD, display name validation
|
||||
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth code exchange
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC service implementation
|
||||
|
||||
**Modify:**
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthorizationInterceptor.scala`
|
||||
- Replace Basic Auth parsing with JWT validation
|
||||
- Skip auth for public endpoints (GetOAuthUrl, ExchangeCode, RefreshToken)
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthorizationUtils.scala`
|
||||
- Change context keys from `userName` to `userId` + `displayName`
|
||||
- `src/main/scala/net/eagle0/eagle/service/Main.scala`
|
||||
- Wire up new auth services and JWT key loading
|
||||
|
||||
### Phase 3: Unity Client OAuth Flow
|
||||
|
||||
**New C# files:**
|
||||
- `Assets/Auth/OAuthManager.cs` - OAuth flow + deep link handling
|
||||
- `Assets/Auth/TokenStorage.cs` - Secure token persistence
|
||||
- `Assets/Auth/AuthClient.cs` - gRPC client for auth service
|
||||
|
||||
**Modify:**
|
||||
- `Assets/EagleConnection.cs`
|
||||
- Replace `AuthInterceptor` (Basic Auth) with `JwtAuthInterceptor` (Bearer token)
|
||||
- `Assets/ConnectionHandler/ConnectionHandler.cs`
|
||||
- Replace username/password UI with Discord/Google login buttons
|
||||
- Add display name setup flow for new users
|
||||
|
||||
### Phase 4: Platform Configuration
|
||||
|
||||
**Deep link registration:**
|
||||
- iOS: Add `eagle0://` to CFBundleURLSchemes in Info.plist
|
||||
- Android: Add intent-filter for `eagle0://auth` in AndroidManifest.xml
|
||||
- Desktop: Register URL scheme (Windows registry / macOS plist)
|
||||
|
||||
**OAuth provider setup:**
|
||||
1. Discord Developer Portal: Create app, add redirect URI `eagle0://auth/callback`
|
||||
2. Google Cloud Console: Create OAuth client, add redirect URI
|
||||
|
||||
**Environment variables (server):**
|
||||
```
|
||||
DISCORD_CLIENT_ID
|
||||
DISCORD_CLIENT_SECRET
|
||||
GOOGLE_CLIENT_ID
|
||||
GOOGLE_CLIENT_SECRET
|
||||
JWT_PRIVATE_KEY_PATH
|
||||
JWT_PUBLIC_KEY_PATH
|
||||
```
|
||||
|
||||
### Phase 5: Testing
|
||||
|
||||
**Unit tests:**
|
||||
- `JwtServiceSpec.scala` - Token creation/validation
|
||||
- `UserServiceSpec.scala` - Display name validation, uniqueness
|
||||
- `OAuthServiceSpec.scala` - OAuth flow with mocked providers
|
||||
|
||||
**Integration tests:**
|
||||
- Full OAuth flow with mock provider
|
||||
- JWT validation in AuthorizationInterceptor
|
||||
- gRPC calls with valid/invalid tokens
|
||||
|
||||
**Manual testing:**
|
||||
- [ ] Discord login (Windows, macOS)
|
||||
- [ ] Google login (Windows, macOS)
|
||||
- [ ] Deep link callback works
|
||||
- [ ] Display name validation
|
||||
- [ ] Session persistence across restarts
|
||||
- [ ] Token refresh
|
||||
|
||||
## Files Summary
|
||||
|
||||
### Create
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/main/protobuf/net/eagle0/eagle/api/auth.proto` | Auth API definitions |
|
||||
| `src/main/protobuf/net/eagle0/eagle/internal/user.proto` | User storage schema |
|
||||
| `src/main/scala/net/eagle0/eagle/auth/OAuthConfig.scala` | Provider config |
|
||||
| `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` | JWT handling |
|
||||
| `src/main/scala/net/eagle0/eagle/auth/UserService.scala` | User management |
|
||||
| `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` | OAuth flow |
|
||||
| `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` | gRPC service |
|
||||
| `Assets/Auth/OAuthManager.cs` | Unity OAuth manager |
|
||||
| `Assets/Auth/TokenStorage.cs` | Token storage |
|
||||
| `Assets/Auth/AuthClient.cs` | Auth gRPC client |
|
||||
|
||||
### Modify
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `AuthorizationInterceptor.scala` | Basic Auth -> JWT validation |
|
||||
| `AuthorizationUtils.scala` | userName -> userId + displayName |
|
||||
| `Main.scala` | Wire auth services |
|
||||
| `EagleConnection.cs` | AuthInterceptor -> JwtAuthInterceptor |
|
||||
| `ConnectionHandler.cs` | Login UI -> OAuth buttons + display name |
|
||||
|
||||
### Delete
|
||||
- nginx htpasswd configuration (no longer needed)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **State parameter** - CSRF protection in OAuth flow
|
||||
2. **PKCE** - Consider adding for mobile (enhancement)
|
||||
3. **Secure storage** - Use Keychain (iOS) / Keystore (Android) for tokens
|
||||
4. **Token refresh** - 7-day access tokens with 30-day refresh
|
||||
5. **Rate limiting** - Limit login attempts per IP
|
||||
|
||||
## Dependencies to Add
|
||||
|
||||
**Scala (MODULE.bazel):**
|
||||
- JWT library (e.g., `jwt-scala` or `nimbus-jose-jwt`)
|
||||
- HTTP client (e.g., `sttp` for OAuth requests)
|
||||
|
||||
**Unity:**
|
||||
- Deep linking is built-in (Unity 2021+)
|
||||
- No additional packages required
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
Keep Basic Auth code in a feature branch. Both auth methods can coexist during transition via feature flag if needed.
|
||||
@@ -1,350 +0,0 @@
|
||||
# OAuth Implementation: Next Steps and Design
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The OAuth implementation is functional but has several gaps that need addressing before it's production-ready. This document outlines the known issues, proposes a comprehensive user identity model, and provides a prioritized implementation plan.
|
||||
|
||||
## Current State (Updated January 2026)
|
||||
|
||||
### What Works ✅
|
||||
- Discord OAuth flow (server-mediated polling)
|
||||
- Google OAuth flow
|
||||
- JWT token generation and validation
|
||||
- User creation and display name setting
|
||||
- Auto-login with stored tokens
|
||||
- Basic game creation and play with OAuth users
|
||||
- Headshot fetching via public CDN (no auth required)
|
||||
- Logout button in lobby (preserves tokens for quick reconnect)
|
||||
- Environment (prod/qa) and user display in lobby
|
||||
- Game identity with userName = displayName (PR #4964 merged)
|
||||
|
||||
### Known Issues
|
||||
|
||||
#### 1. Game Identity Model Fragility (Deferred)
|
||||
**Status**: Accepted for now. PR #4964 merged with `userName = displayName`.
|
||||
|
||||
**Current behavior**:
|
||||
- Games store `userNameToFactionId: Map[String, Int]`
|
||||
- For JWT users, this maps displayName → factionId
|
||||
- displayName is technically mutable (users could change it)
|
||||
- No migration path when displayName changes
|
||||
|
||||
**Why this is acceptable**:
|
||||
1. We don't currently have a "change display name" feature
|
||||
2. The alternative (using userId) requires more extensive changes
|
||||
3. Can migrate to userId-based identity later if needed
|
||||
|
||||
#### 2. In-Game Headshot Fetching ✅ FIXED
|
||||
**Solution**: Made the `eagle0-headshots` S3 bucket public and enabled CDN.
|
||||
- Client now fetches directly from `https://eagle0-headshots.sfo3.cdn.digitaloceanspaces.com/`
|
||||
- No authentication required
|
||||
- Works for both OAuth and Basic Auth users
|
||||
- Simpler architecture, no dependency on home Mac server
|
||||
|
||||
#### 3. Logout from Lobby ✅ FIXED
|
||||
**Solution**: Added logout button to lobby UI (PR #4967).
|
||||
- Button disconnects from server and returns to connection screen
|
||||
- Intentionally does NOT clear OAuth tokens
|
||||
- Allows quick reconnect with same account without full OAuth flow
|
||||
|
||||
#### 4. Display Name Uniqueness Not Enforced (Medium) - OPEN
|
||||
**Problem**: User was able to set displayName "nolen" when that name was already taken.
|
||||
|
||||
**Root cause**: Unknown - needs investigation. Either:
|
||||
- The uniqueness check is buggy
|
||||
- The displayNameIndex wasn't populated correctly during user creation
|
||||
- Race condition during concurrent registrations
|
||||
|
||||
#### 5. Admin Server Crashes ✅ FIXED
|
||||
**Solution**: PR #4964 sets `userName = displayName` for JWT users.
|
||||
|
||||
#### 6. Intermittent "Expired" Errors During Login (Medium) - INVESTIGATING
|
||||
**Problem**: Users occasionally get "OAuth session expired" errors even when server logs show the callback succeeded.
|
||||
|
||||
**Status**: Added diagnostic logging in PR #4974 to trace:
|
||||
- State creation in `getAuthUrl`
|
||||
- State lookup in `handleCallback`
|
||||
- Result lookup in `checkStatus`
|
||||
|
||||
**Possible causes**:
|
||||
- State mismatch between client and server
|
||||
- Race condition in polling
|
||||
- Cleanup running at wrong time
|
||||
|
||||
#### 7. Token Expiry Field Bug ✅ FIXED
|
||||
**Problem**: `CheckOAuthStatusResponse.expiresAt` was returning refresh token expiry (30 days) instead of access token expiry (7 days).
|
||||
|
||||
**Solution**: Fixed in PR #4974 to calculate correct access token expiry.
|
||||
|
||||
---
|
||||
|
||||
## Proposed User Identity Model
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Stable Internal Identity**: `userId` (UUID) is the only key used for persistent associations
|
||||
2. **Display Name is Cosmetic**: Can change without breaking game associations
|
||||
3. **Backwards Compatibility**: Basic Auth continues to work for local development
|
||||
4. **Multi-Provider Support**: Users can link Discord, Google, and future providers
|
||||
5. **Avatar Flexibility**: Use OAuth avatar by default, support custom uploads later
|
||||
|
||||
### Data Model
|
||||
|
||||
```
|
||||
User {
|
||||
userId: String (UUID) // Primary key, immutable, used for all internal references
|
||||
displayName: String // Unique, user-visible, mutable with migration
|
||||
displayNameLower: String // Case-insensitive uniqueness
|
||||
email: String // Primary email for account recovery/linking
|
||||
avatarUrl: String // Current avatar URL
|
||||
avatarData: bytes // Cached avatar for offline/fast access (future)
|
||||
oauthIdentities: [OAuthIdentity]
|
||||
createdAt: Timestamp
|
||||
lastLoginAt: Timestamp
|
||||
isAdmin: Boolean
|
||||
}
|
||||
|
||||
OAuthIdentity {
|
||||
provider: String // "discord", "google", etc.
|
||||
providerUserId: String // Provider's user ID
|
||||
providerEmail: String // Email from this provider
|
||||
avatarUrl: String // Avatar from this provider
|
||||
linkedAt: Timestamp
|
||||
}
|
||||
```
|
||||
|
||||
### Identity Resolution Strategy
|
||||
|
||||
The key question: **What should `AuthorizationUtils.userName` return?**
|
||||
|
||||
#### Option A: userName = displayName (Current PR #4964)
|
||||
- **Pro**: Human-readable in logs, game saves, debugging
|
||||
- **Con**: Breaks if displayName changes
|
||||
- **Migration**: None needed now, complex later
|
||||
|
||||
#### Option B: userName = userId (Recommended)
|
||||
- **Pro**: Stable identity, displayName changes are safe
|
||||
- **Con**: UUIDs in logs are ugly, need display name lookup for UI
|
||||
- **Migration**: Cleaner long-term, but breaking change for any existing OAuth games
|
||||
|
||||
#### Option C: Hybrid with Migration Support
|
||||
- **userName** = userId for new games
|
||||
- **Legacy lookup** for old games by displayName
|
||||
- **Display layer** resolves userId → displayName for UI
|
||||
|
||||
**Recommendation**: Option B with a display name resolution layer. The ugliness in logs is acceptable for the stability it provides. Implement a `UserService.resolveDisplayName(identifier: String): String` that returns displayName for UUIDs or the identifier itself for legacy usernames.
|
||||
|
||||
### Account Linking Strategy
|
||||
|
||||
#### Automatic Linking (Future)
|
||||
When a user logs in with a new OAuth provider:
|
||||
1. Check if the provider email matches an existing user's email
|
||||
2. If match found, prompt: "An account exists with this email. Link accounts?"
|
||||
3. If confirmed, add new OAuthIdentity to existing user
|
||||
4. If declined, create separate account (different email required)
|
||||
|
||||
#### Manual Linking (MVP)
|
||||
1. User logs in with primary account
|
||||
2. User goes to Settings → Linked Accounts
|
||||
3. User clicks "Link Discord" or "Link Google"
|
||||
4. OAuth flow adds new identity to current user
|
||||
|
||||
### Avatar/Headshot Strategy
|
||||
|
||||
#### Phase 1: OAuth Avatars (MVP)
|
||||
- Store `avatarUrl` from OAuth provider during login
|
||||
- Server proxies avatar requests to avoid CORS issues
|
||||
- Cache avatars locally with TTL
|
||||
|
||||
#### Phase 2: Avatar Caching
|
||||
- Download avatar to local storage on login
|
||||
- Serve from local storage for reliability
|
||||
- Refresh periodically or on login
|
||||
|
||||
#### Phase 3: Custom Avatars (Future)
|
||||
- Allow users to upload custom avatar
|
||||
- Store in S3/DO Spaces
|
||||
- Custom avatar overrides OAuth avatar
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Stabilization ✅ COMPLETE
|
||||
|
||||
#### 1.1 Fix Display Name Uniqueness Bug - OPEN
|
||||
- [ ] Investigate why "nolen" was allowed when it existed
|
||||
- [ ] Add logging to `setDisplayName` to trace the issue
|
||||
- [ ] Ensure `displayNameIndex` is correctly maintained
|
||||
- [ ] Add unit tests for uniqueness enforcement
|
||||
|
||||
#### 1.2 Add Logout Button to Lobby ✅ DONE
|
||||
- [x] Add "Logout" button to lobby UI
|
||||
- [x] Disconnect from server
|
||||
- [x] Navigate to connection screen
|
||||
- [x] Preserve OAuth tokens for quick reconnect (intentional change from original plan)
|
||||
|
||||
#### 1.3 Merge PR #4964 (userName = displayName) ✅ DONE
|
||||
- [x] Merged - games work with OAuth users
|
||||
- [x] Documented limitation (games break if displayName changes)
|
||||
|
||||
#### 1.4 Fix Headshot Fetching ✅ DONE
|
||||
- [x] Made eagle0-headshots bucket public
|
||||
- [x] Client fetches directly from CDN
|
||||
- [x] No authentication required
|
||||
|
||||
#### 1.5 Add Lobby Status Display ✅ DONE
|
||||
- [x] Show environment (prod/qa) in lobby
|
||||
- [x] Show current user in lobby (OAuth displayName or classic username)
|
||||
|
||||
### Phase 2: Remaining Work (Priority Order)
|
||||
|
||||
#### 2.1 Diagnose Intermittent "Expired" Errors - IN PROGRESS
|
||||
- [x] Add diagnostic logging (PR #4974)
|
||||
- [ ] Deploy and reproduce the issue
|
||||
- [ ] Analyze logs to identify root cause
|
||||
- [ ] Implement fix based on findings
|
||||
|
||||
#### 2.2 Fix Display Name Uniqueness
|
||||
- [ ] Investigate UserService.setDisplayName logic
|
||||
- [ ] Check displayNameIndex population
|
||||
- [ ] Add logging to trace the issue
|
||||
- [ ] Fix the bug and add tests
|
||||
|
||||
#### 2.3 Wire Up Lobby UI in Unity
|
||||
- [ ] Connect `lobbyEnvironmentText` to TextMeshProUGUI in scene
|
||||
- [ ] Connect `lobbyUserText` to TextMeshProUGUI in scene
|
||||
|
||||
#### 2.4 Implement Token Refresh During Gameplay
|
||||
- [ ] Implement `RefreshToken` RPC on server (currently throws UNIMPLEMENTED)
|
||||
- [ ] Store refresh tokens server-side for validation
|
||||
- [ ] Add proactive refresh in client before token expires
|
||||
- [ ] Handle refresh during reconnection attempts
|
||||
|
||||
### Phase 3: Nice-to-Haves (Future)
|
||||
|
||||
#### 3.1 Proactive Token Refresh
|
||||
- [ ] Monitor token expiry in client
|
||||
- [ ] Refresh automatically when < 5 minutes remaining
|
||||
- [ ] Update TokenStorage with new access token
|
||||
|
||||
#### 3.2 Better Error Messages
|
||||
- [ ] Distinguish between network errors and auth errors
|
||||
- [ ] Show user-friendly messages for OAuth failures
|
||||
- [ ] Add retry suggestions
|
||||
|
||||
#### 3.3 Session Persistence Across Server Restarts
|
||||
- [ ] Move pendingOAuth from in-memory TrieMap to Redis/database
|
||||
- [ ] Move completedOAuth to Redis with TTL
|
||||
- [ ] Server can restart without breaking in-flight OAuth flows
|
||||
|
||||
#### 3.4 Migrate to userId-based Game Identity (Deferred)
|
||||
- [ ] Change `AuthorizationUtils.userName` to return `userId` for JWT users
|
||||
- [ ] Add `UserService.resolveDisplayName(id: String): String` for UI display
|
||||
- [ ] Update game UI to resolve userIds to displayNames
|
||||
- [ ] Existing Basic Auth games continue to work (userName is literal)
|
||||
|
||||
#### 3.5 Display Name Change Support (Requires 3.4)
|
||||
- [ ] Add `ChangeDisplayName` RPC
|
||||
- [ ] Validate new name is unique
|
||||
- [ ] Update user record
|
||||
- [ ] No game migration needed (games use userId)
|
||||
|
||||
### Phase 3: Multi-Provider Support (Future)
|
||||
|
||||
#### 3.1 Account Linking UI
|
||||
- [ ] Add Settings page with "Linked Accounts" section
|
||||
- [ ] Show currently linked providers
|
||||
- [ ] "Link Another Account" button triggers OAuth flow
|
||||
- [ ] `LinkOAuthProvider` RPC adds identity to current user
|
||||
|
||||
#### 3.2 Login Provider Selection
|
||||
- [ ] If user has multiple providers, any can be used to login
|
||||
- [ ] All resolve to same userId
|
||||
- [ ] Session shows which provider was used
|
||||
|
||||
#### 3.3 Account Merging (Complex)
|
||||
- [ ] Handle case where user created separate accounts
|
||||
- [ ] Merge game history, stats, etc.
|
||||
- [ ] Delete duplicate user record
|
||||
- [ ] This is complex - may defer or not implement
|
||||
|
||||
### Phase 4: Enhanced Avatars (Future)
|
||||
|
||||
#### 4.1 Avatar Caching
|
||||
- [ ] Download avatars to S3/DO Spaces on login
|
||||
- [ ] Serve from our CDN
|
||||
- [ ] Refresh on login if changed
|
||||
|
||||
#### 4.2 Custom Avatar Upload
|
||||
- [ ] Upload endpoint with size/format validation
|
||||
- [ ] Store in S3/DO Spaces
|
||||
- [ ] Custom avatar overrides OAuth avatar
|
||||
|
||||
---
|
||||
|
||||
## Technical Debt to Address
|
||||
|
||||
1. **Context Propagation in Futures**: PR #4960 fixed `setDisplayName` and `getCurrentUser`, but audit all `Future` blocks that access `AuthorizationUtils`
|
||||
|
||||
2. **Dual Auth Support**: The system supports both Basic Auth and JWT. Consider:
|
||||
- Should Basic Auth be deprecated for production?
|
||||
- Should it remain for local development only?
|
||||
- How do Basic Auth users interact with OAuth users in the same game?
|
||||
|
||||
3. **Token Refresh**: `RefreshToken` RPC throws UNIMPLEMENTED. Need to:
|
||||
- Implement refresh token storage and validation
|
||||
- Handle token refresh in client
|
||||
- Consider refresh token rotation for security
|
||||
|
||||
4. **Session Management**: No server-side session tracking. Consider:
|
||||
- Track active sessions per user
|
||||
- Allow "logout all devices"
|
||||
- Detect concurrent logins
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **What happens when a Basic Auth user and OAuth user have the same name?**
|
||||
- Currently possible - Basic Auth doesn't check UserService
|
||||
- Could cause confusion in games
|
||||
- Solution: Require OAuth for multiplayer? Or namespace Basic Auth names?
|
||||
|
||||
2. **Should displayName changes be allowed?**
|
||||
- With userId-based identity, it's safe
|
||||
- But could cause confusion ("who is this new player?")
|
||||
- Consider: rate limit changes, show "formerly known as" temporarily
|
||||
|
||||
3. **How to handle OAuth provider account deletion?**
|
||||
- User deletes their Discord account
|
||||
- Their Eagle0 account still exists
|
||||
- They can't login unless they linked another provider
|
||||
- Solution: Encourage linking multiple providers, or add email/password fallback
|
||||
|
||||
4. **Admin impersonation with OAuth**
|
||||
- Currently works via X-Impersonate-User header
|
||||
- Should this use userId or displayName?
|
||||
- Probably userId for stability
|
||||
|
||||
---
|
||||
|
||||
## Appendix: File Locations
|
||||
|
||||
### Server (Scala)
|
||||
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD
|
||||
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - Token generation/validation
|
||||
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth flow
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC Auth service
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthorizationInterceptor.scala` - Auth middleware
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthorizationUtils.scala` - Context accessors
|
||||
|
||||
### Client (C#)
|
||||
- `Assets/Auth/AuthClient.cs` - gRPC client for Auth service
|
||||
- `Assets/Auth/OAuthManager.cs` - OAuth flow orchestration
|
||||
- `Assets/Auth/TokenStorage.cs` - Persistent token storage
|
||||
- `Assets/Auth/JwtAuthInterceptor.cs` - Attaches JWT to requests
|
||||
|
||||
### Protos
|
||||
- `src/main/protobuf/net/eagle0/eagle/api/auth.proto` - Auth service definition
|
||||
- `src/main/protobuf/net/eagle0/eagle/internal/user/user.proto` - User data model
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,205 +0,0 @@
|
||||
# Scala 3 Modernization Guide
|
||||
|
||||
## Overview
|
||||
This document outlines opportunities to modernize the Eagle0 codebase to use Scala 3 best practices and features. The migration to Scala 3 is complete, but the code still uses many Scala 2 patterns that can be improved.
|
||||
|
||||
## Modernization Opportunities
|
||||
|
||||
### 1. **Convert Sealed Traits to Enums** 🎯 HIGH IMPACT
|
||||
**Benefits**: Better performance, more concise syntax, improved exhaustiveness checking
|
||||
|
||||
**Current pattern** (`ExternalTextGenerationCaller.scala:23-31`):
|
||||
```scala
|
||||
sealed trait ExternalTextGenerationError extends Error {
|
||||
def message: String
|
||||
}
|
||||
case class ExternalTextGenerationRateLimitError(code: Int, message: String)
|
||||
extends ExternalTextGenerationError
|
||||
case class ExternalTextGenerationHttpError(code: Int, message: String)
|
||||
extends ExternalTextGenerationError
|
||||
case class ExternalTextGenerationTimeoutError(message: String)
|
||||
extends ExternalTextGenerationError
|
||||
```
|
||||
|
||||
**Scala 3 improvement**:
|
||||
```scala
|
||||
enum ExternalTextGenerationError extends Error:
|
||||
case RateLimit(code: Int, message: String)
|
||||
case Http(code: Int, message: String)
|
||||
case Timeout(message: String)
|
||||
|
||||
def message: String = this match
|
||||
case RateLimit(_, msg) => msg
|
||||
case Http(_, msg) => msg
|
||||
case Timeout(msg) => msg
|
||||
```
|
||||
|
||||
**Files to check**:
|
||||
- `/src/main/scala/net/eagle0/common/llm_integration/ExternalTextGenerationCaller.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request/GeneratedTextRequestT.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/model/state/quest/concrete/QuestC.scala`
|
||||
|
||||
### 2. **Convert Implicit Classes to Extension Methods** 🎯 HIGH IMPACT
|
||||
**Benefits**: Modern syntax, better IDE support, cleaner imports
|
||||
|
||||
**Current pattern** (`MoreSeq.scala:23-26`):
|
||||
```scala
|
||||
implicit def SeqCollect[A, Repr[_]](coll: Repr[A])(implicit
|
||||
itr: IsIterable[Repr[A]]
|
||||
): SeqCollect[A, Repr, itr.type] =
|
||||
new SeqCollect[A, Repr, itr.type](coll, itr)
|
||||
```
|
||||
|
||||
**Scala 3 improvement**:
|
||||
```scala
|
||||
extension [A, Repr[_]](coll: Repr[A])(using itr: IsIterable[Repr[A]])
|
||||
def flatCollect[B](pf: PartialFunction[itr.A, Option[B]])(using Factory[B, Repr[B]]): Repr[B] =
|
||||
Factory[B, Repr[B]].fromSpecific(itr(coll).collect(pf).flatten)
|
||||
|
||||
def flatCollectFirst[B](pf: PartialFunction[itr.A, Option[B]]): Option[B] =
|
||||
itr(coll).collect(pf).flatten.headOption
|
||||
```
|
||||
|
||||
**Files to check**:
|
||||
- `/src/main/scala/net/eagle0/common/MoreSeq.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/CommandChooser.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/service/new_game_creation/NewGameCreation.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultProtoApplierImpl.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/service/new_game_creation/StartGameActionResultUtils.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/model/state/date/Date.scala`
|
||||
|
||||
### 3. **Convert Implicit Parameters to Using Clauses** 🎯 MEDIUM IMPACT
|
||||
**Benefits**: Cleaner syntax, better tooling support, clearer intent
|
||||
|
||||
**Current pattern**:
|
||||
```scala
|
||||
def method[T](value: T)(implicit ec: ExecutionContext): Future[T]
|
||||
def process[A](items: Seq[A])(implicit ord: Ordering[A]): Seq[A]
|
||||
```
|
||||
|
||||
**Scala 3 improvement**:
|
||||
```scala
|
||||
def method[T](value: T)(using ExecutionContext): Future[T]
|
||||
def process[A](items: Seq[A])(using Ordering[A]): Seq[A]
|
||||
```
|
||||
|
||||
**Files to check**:
|
||||
- `/src/main/scala/net/eagle0/common/MoreSeq.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/library/util/hero_name_fetcher/HeroNameFetcher.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/library/util/ShardokMapInfo.scala`
|
||||
- `/src/main/scala/net/eagle0/common/llm_integration/OpenAIChatCompletionsServiceImpl.scala`
|
||||
- `/src/main/scala/net/eagle0/common/llm_integration/ClaudeServiceImpl.scala`
|
||||
|
||||
### 4. **Opaque Types for Type Safety** 🎯 MEDIUM IMPACT
|
||||
**Benefits**: Zero runtime cost, compile-time type safety, prevents mixing up similar types
|
||||
|
||||
**Pattern to look for**: Type aliases that represent distinct concepts
|
||||
```scala
|
||||
// Instead of: type UserId = String, type GameId = String
|
||||
opaque type UserId = String
|
||||
object UserId:
|
||||
def apply(s: String): UserId = s
|
||||
extension (id: UserId)
|
||||
def value: String = id
|
||||
def isValid: Boolean = id.nonEmpty && id.length > 3
|
||||
|
||||
opaque type GameId = Long
|
||||
object GameId:
|
||||
def apply(l: Long): GameId = l
|
||||
extension (id: GameId) def value: Long = id
|
||||
```
|
||||
|
||||
**Candidates**: Look for simple type aliases and ID types throughout the codebase.
|
||||
|
||||
### 5. **Inline Methods for Performance** 🎯 LOW IMPACT
|
||||
**Benefits**: Compile-time optimization, better performance for hot paths
|
||||
|
||||
**Pattern**: Mark small, frequently-called methods as `inline`
|
||||
```scala
|
||||
inline def isValidId(id: String): Boolean =
|
||||
id.nonEmpty && id.length > 3
|
||||
|
||||
inline def calculateScore(base: Int, multiplier: Double): Double =
|
||||
base * multiplier
|
||||
```
|
||||
|
||||
**Candidates**: Small utility methods in performance-critical paths (AI calculations, game state updates).
|
||||
|
||||
### 6. **Union Types Instead of Complex Hierarchies** 🎯 LOW IMPACT
|
||||
**Benefits**: Simpler type definitions for either/or scenarios
|
||||
|
||||
**Pattern**: Simple sealed traits with only case classes
|
||||
```scala
|
||||
// Instead of:
|
||||
sealed trait Result
|
||||
case class Success(value: String) extends Result
|
||||
case class Error(message: String) extends Result
|
||||
|
||||
// Consider:
|
||||
type Result = Success | Error
|
||||
case class Success(value: String)
|
||||
case class Error(message: String)
|
||||
```
|
||||
|
||||
### 7. **Context Functions for Cleaner APIs** 🎯 LOW IMPACT
|
||||
**Benefits**: Cleaner API design, implicit context passing
|
||||
|
||||
**Pattern**: Replace implicit function parameters
|
||||
```scala
|
||||
// Old
|
||||
type Handler = GameState => Unit
|
||||
def withGameState(gs: GameState)(handler: Handler): Unit = handler(gs)
|
||||
|
||||
// New
|
||||
type Handler = GameState ?=> Unit
|
||||
def withGameState(gs: GameState)(handler: Handler): Unit =
|
||||
given GameState = gs
|
||||
handler
|
||||
```
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
### Phase 1: Quick Wins (High Impact, Low Risk)
|
||||
1. **Convert Extension Methods** in `MoreSeq.scala` - immediate readability improvement
|
||||
2. **Update Using Clauses** - simple find/replace operation
|
||||
3. **Convert Simple Sealed Traits to Enums** - start with error types
|
||||
|
||||
### Phase 2: Type Safety Improvements
|
||||
4. **Add Opaque Types** for IDs and measurements - improves type safety
|
||||
5. **Inline Performance-Critical Methods** - measure before/after impact
|
||||
|
||||
### Phase 3: Advanced Features (Lower Priority)
|
||||
6. **Union Types** where appropriate - only for simple either/or cases
|
||||
7. **Context Functions** for complex API improvements
|
||||
|
||||
## Implementation Guidelines
|
||||
|
||||
### Style Consistency
|
||||
- **Keep curly braces**: Continue using Scala 2 style `{}` instead of indentation-based syntax
|
||||
- **Gradual adoption**: Modernize files as they're touched for other reasons
|
||||
- **Test thoroughly**: Each modernization should include verification that behavior is unchanged
|
||||
|
||||
### Performance Considerations
|
||||
- **Measure enum performance**: Verify that enum conversion actually improves performance in hot paths
|
||||
- **Benchmark inline methods**: Use profiling to confirm performance gains
|
||||
- **Consider compilation time**: Some features may increase compile time
|
||||
|
||||
### Migration Strategy
|
||||
- **File-by-file approach**: Complete modernization of one file at a time
|
||||
- **Separate PRs**: Each modernization type should be its own PR for easier review
|
||||
- **Documentation**: Update this document as patterns are modernized
|
||||
|
||||
## Success Criteria
|
||||
- [ ] All extension methods converted from implicit classes
|
||||
- [ ] All implicit parameters converted to using clauses
|
||||
- [ ] Key sealed traits converted to enums where appropriate
|
||||
- [ ] Opaque types introduced for important ID types
|
||||
- [ ] Performance-critical methods marked as inline (with benchmarks)
|
||||
- [ ] No regression in functionality or performance
|
||||
- [ ] Code remains readable and maintainable
|
||||
|
||||
## Notes
|
||||
- Focus on high-impact, low-risk improvements first
|
||||
- Each change should be driven by clear benefits (performance, readability, type safety)
|
||||
- Maintain backward compatibility where possible
|
||||
- Document any breaking changes clearly
|
||||
@@ -1,216 +0,0 @@
|
||||
# Shardok Latency Hiding Strategies
|
||||
|
||||
## Problem Statement
|
||||
|
||||
With Shardok running on Hetzner (Helsinki) and Eagle on DigitalOcean (US), the round-trip latency for human commands is ~200-400ms:
|
||||
|
||||
```
|
||||
Human posts command:
|
||||
Unity → Eagle (DO) → Shardok (Hetzner) → Eagle (DO) → Unity
|
||||
~10ms ~100ms ~100ms ~10ms
|
||||
Total: ~220ms round-trip
|
||||
```
|
||||
|
||||
This latency is acceptable for AI turns (users watch animations anyway), but creates noticeable lag when humans post commands.
|
||||
|
||||
---
|
||||
|
||||
## Strategy 1: Client-Side Animation Masking
|
||||
|
||||
### Concept
|
||||
|
||||
Start animations immediately when the user clicks, before server confirmation arrives. The animation duration masks the network latency.
|
||||
|
||||
### Implementation by Command Type
|
||||
|
||||
**Movement Commands** (deterministic):
|
||||
- Client knows the destination hex and movement path
|
||||
- Start movement animation immediately on click
|
||||
- Server confirms the move (should always match)
|
||||
- If server rejects (invalid state), snap unit back to origin
|
||||
|
||||
**Attack Commands** (RNG-dependent):
|
||||
- Show attack animation immediately (unit swings sword, fires arrow)
|
||||
- Wait for server to return dice roll result
|
||||
- Show damage numbers / hit effects when server responds
|
||||
- Animation typically takes 300-500ms, masking most of the latency
|
||||
|
||||
**End Turn**:
|
||||
- Latency not noticeable (user expects transition delay)
|
||||
|
||||
### Unity Implementation Sketch
|
||||
|
||||
```csharp
|
||||
// In CommandHandler.cs
|
||||
public void OnCommandSelected(Command command) {
|
||||
// Start animation immediately
|
||||
if (command.Type == CommandType.Move) {
|
||||
unitController.StartMoveAnimation(command.TargetHex);
|
||||
} else if (command.Type == CommandType.Attack) {
|
||||
unitController.StartAttackAnimation(command.TargetUnit);
|
||||
}
|
||||
|
||||
// Send to server in parallel
|
||||
connection.SendCommand(command, (response) => {
|
||||
if (response.Success) {
|
||||
// Animation continues, apply result
|
||||
ApplyCommandResult(response);
|
||||
} else {
|
||||
// Rollback animation
|
||||
unitController.CancelAnimation();
|
||||
ShowError(response.ErrorMessage);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Pros
|
||||
- Simple implementation
|
||||
- No server-side changes
|
||||
- Works with existing architecture
|
||||
|
||||
### Cons
|
||||
- Doesn't eliminate latency for attacks with RNG (must wait for dice roll)
|
||||
- Rollback needed if server rejects command (rare but possible)
|
||||
|
||||
### Estimated Improvement
|
||||
- Movement: ~200ms latency hidden (feels instant)
|
||||
- Attacks: ~100-200ms hidden by animation, ~100ms visible wait for dice result
|
||||
|
||||
---
|
||||
|
||||
## Strategy 2: Split Shardok Architecture
|
||||
|
||||
### Concept
|
||||
|
||||
Run two Shardok instances:
|
||||
- **Shardok-Primary (DigitalOcean)**: Handles command processing, source of truth
|
||||
- **Shardok-AI (Hetzner)**: AI computation only
|
||||
|
||||
Human commands go to the nearby Primary for low latency. AI computation uses the powerful Hetzner instance.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Human commands (low latency ~20ms)
|
||||
Unity ←→ Eagle ←→ Shardok-Primary (DigitalOcean)
|
||||
↓ state sync (when AI turn starts)
|
||||
Shardok-AI (Hetzner)
|
||||
↑ AI command response
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
**Human Turn:**
|
||||
1. Human posts command → Eagle → Shardok-Primary (DO)
|
||||
2. Primary processes command immediately (~10ms local)
|
||||
3. Primary streams result to client via Eagle (~10ms)
|
||||
4. **Total latency: ~20ms** (vs ~220ms current)
|
||||
|
||||
**AI Turn:**
|
||||
1. When AI's turn starts, Primary sends game state snapshot to Hetzner
|
||||
2. Shardok-AI computes best command using full CPU power
|
||||
3. Shardok-AI returns command index to Primary
|
||||
4. Primary executes command locally and streams to client
|
||||
5. Repeat until AI turn ends
|
||||
|
||||
### Protocol Changes
|
||||
|
||||
```protobuf
|
||||
// New service for AI-only computation
|
||||
service ShardokAIService {
|
||||
// Send game state, receive AI's chosen command
|
||||
rpc GetAICommand(AICommandRequest) returns (AICommandResponse);
|
||||
}
|
||||
|
||||
message AICommandRequest {
|
||||
bytes game_state = 1; // Serialized game state
|
||||
int32 player_id = 2; // Which AI player
|
||||
repeated bytes available_commands = 3; // Available command descriptors
|
||||
}
|
||||
|
||||
message AICommandResponse {
|
||||
int32 command_index = 1; // Index into available_commands
|
||||
int32 search_depth = 2; // For debugging
|
||||
double best_score = 3; // For debugging
|
||||
}
|
||||
```
|
||||
|
||||
### Shardok-Primary Requirements
|
||||
|
||||
Shardok-Primary on DigitalOcean needs to:
|
||||
- Process all commands (human and AI)
|
||||
- Maintain authoritative game state
|
||||
- Serialize/deserialize game state for AI requests
|
||||
- Run on minimal CPU (command processing is fast)
|
||||
|
||||
This is essentially the current Shardok, but without running the AI search.
|
||||
|
||||
### Shardok-AI Requirements
|
||||
|
||||
Shardok-AI on Hetzner needs to:
|
||||
- Receive game state snapshots
|
||||
- Run AI evaluation (IterativeDeepeningAI or MCTS)
|
||||
- Return best command index
|
||||
- No persistent state (stateless worker)
|
||||
|
||||
### AI Turn Latency
|
||||
|
||||
Each AI command has ~200ms network latency. This is acceptable because:
|
||||
1. User is watching animations anyway
|
||||
2. Natural pacing lets user observe AI decisions
|
||||
3. AI computation is fast on Hetzner's 16 cores
|
||||
|
||||
For a typical AI turn with 5 commands: 5 × 200ms = 1 second network overhead, plus AI thinking time. With animations, this feels natural.
|
||||
|
||||
### Implementation Phases
|
||||
|
||||
**Phase 1: Add Shardok-Primary (minimal)**
|
||||
- Deploy existing Shardok container to DigitalOcean
|
||||
- Configure Eagle to use local Shardok for all commands
|
||||
- Human latency immediately improves
|
||||
|
||||
**Phase 2: Add AI offload**
|
||||
- Implement `ShardokAIService` RPC
|
||||
- Shardok-Primary calls Hetzner for AI commands
|
||||
- Shardok-AI processes requests statelessly
|
||||
|
||||
**Phase 3: Optimize**
|
||||
- Batch multiple AI actions if possible
|
||||
- Pre-warm Shardok-AI connection
|
||||
- Add fallback if Hetzner unavailable
|
||||
|
||||
### Pros
|
||||
- Human command latency drops from ~220ms to ~20ms
|
||||
- AI still gets Hetzner's CPU power
|
||||
- Clear separation of concerns
|
||||
- Shardok-Primary can fall back to local AI if Hetzner unavailable
|
||||
|
||||
### Cons
|
||||
- Two Shardok instances to maintain
|
||||
- State serialization overhead for AI requests
|
||||
- Each AI action has network round-trip (acceptable with animations)
|
||||
|
||||
---
|
||||
|
||||
## Comparison
|
||||
|
||||
| Approach | Human Latency | AI Throughput | Complexity | Changes Required |
|
||||
|----------|---------------|---------------|------------|------------------|
|
||||
| Current | ~220ms | High | - | - |
|
||||
| Animation masking | ~220ms (perceived ~50ms) | High | Low | Unity only |
|
||||
| Split architecture | ~20ms | High | Medium | New RPC, two deployments |
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Phase 1 (now)**: Implement animation masking in Unity client
|
||||
- Quick win, no server changes
|
||||
- Improves perceived latency significantly for movement
|
||||
- Attacks still show dice animation while waiting
|
||||
|
||||
**Phase 2 (future)**: Split architecture if animation masking insufficient
|
||||
- Only needed if users complain about attack latency
|
||||
- More complex but provides true low latency
|
||||
- Natural evolution of current architecture
|
||||
@@ -1,181 +0,0 @@
|
||||
# Tutorial Content Guide
|
||||
|
||||
This document defines all tutorial content. Edit this to refine the text, then update `TutorialContentDefinitions.cs` to match.
|
||||
|
||||
---
|
||||
|
||||
## Onboarding Sequence
|
||||
|
||||
Shown to first-time players. Guides them through the basics of strategic and tactical gameplay.
|
||||
|
||||
| Step | ID | Display | Trigger | Title | Description |
|
||||
|------|-----|---------|---------|-------|-------------|
|
||||
| 1 | `welcome` | Modal | Auto (game start) | Welcome to Eagle0 | Command your armies, recruit heroes, and conquer provinces in this turn-based strategy game.<br><br>Let's walk through the basics! |
|
||||
| 2 | `select_province` | Overlay | Completes on: `province_selected` | The Strategic Map | This is your kingdom. Each colored region is a province.<br><br>Tap a province you control (shown in your color) to see what you can do there. |
|
||||
| 3 | `province_panel` | Modal | Button click | Province Information | This panel shows province details: its name, terrain, any armies present, and the commands available to you.<br><br>Commands let you move troops, recruit heroes, and more. |
|
||||
| 4 | `try_march` | Overlay | Completes on: `command_issued` | Issue a Command | Try issuing a March command to move your army to an adjacent province.<br><br>Select a destination and confirm the order. |
|
||||
| 5 | `turn_cycle` | Modal | Button click | The Turn Cycle | Eagle0 uses simultaneous turns. All players give orders at the same time, then turns resolve together.<br><br>When all players are ready, the server processes everyone's commands and shows the results. |
|
||||
| 6 | `wait_for_battle` | Hidden | Completes on: `first_battle_available` | *(none)* | *(Invisible step - waits for a battle to become available)* |
|
||||
| 7 | `battle_intro` | Modal | Button click | Battle Time! | When armies collide, you'll fight tactical battles on a hex grid.<br><br>You command individual units - infantry, cavalry, archers, and heroes with special abilities. |
|
||||
| 8 | `enter_battle` | Overlay | Completes on: `battle_entered` | Enter the Battle | Tap the Battle button to enter tactical combat. |
|
||||
| 9 | `tactical_overview` | Modal | Button click | Tactical Combat | Each unit has movement points and attack power. Position your troops wisely!<br><br>Units attack adjacent enemies. Flanking (attacking from multiple sides) deals bonus damage. |
|
||||
| 10 | `move_unit` | Overlay | Completes on: `battle_action` | Move Your Units | Tap one of your units to select it, then tap a highlighted hex to move there.<br><br>Blue hexes show where you can move. |
|
||||
| 11 | `attack_enemy` | Overlay | Completes on: `battle_action` | Attack! | Move next to an enemy unit, then tap the enemy to attack.<br><br>Red highlights show valid attack targets. |
|
||||
| 12 | `end_turn` | Overlay | Completes on: `turn_ended` | End Your Turn | When you've moved all units or want to pass, tap End Turn.<br><br>The enemy will then take their turn. |
|
||||
| 13 | `complete` | Modal | Button click (no skip) | You're Ready! | You now know the basics of Eagle0!<br><br>Explore diplomacy, recruit powerful heroes, and conquer the realm. Good luck, commander! |
|
||||
|
||||
### Notes on Onboarding Flow
|
||||
|
||||
- Steps 1-5 cover strategic gameplay
|
||||
- Step 6 is invisible - just waits for a battle
|
||||
- Steps 7-12 cover tactical combat
|
||||
- Step 13 celebrates completion
|
||||
|
||||
**Questions to consider:**
|
||||
- Should we skip tactical tutorial if player skips to first battle themselves?
|
||||
- Should there be a "skip all" option visible from step 1?
|
||||
- Is the step order correct for typical first-game flow?
|
||||
|
||||
---
|
||||
|
||||
## Strategic Contextual Tutorials
|
||||
|
||||
Triggered when players encounter features for the first time.
|
||||
|
||||
### Diplomacy Introduction
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `diplomacy_intro` |
|
||||
| Trigger | `diplomacy_available` (diplomacy commands appear) |
|
||||
| Display | Modal |
|
||||
| Title | Diplomacy |
|
||||
| Description | You can negotiate with other factions!<br><br>Offer alliances, declare war, or propose tribute. Your diplomatic choices shape the realm. |
|
||||
|
||||
### Hero Recruitment
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `hero_recruitment` |
|
||||
| Trigger | `hero_recruitment_available` (free heroes detected) |
|
||||
| Display | Modal |
|
||||
| Title | Heroes Available |
|
||||
| Description | Free heroes wander the realm seeking a lord to serve.<br><br>Recruit them to lead your armies! Heroes have unique abilities and grow stronger with experience. |
|
||||
|
||||
### Weather Control
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `weather_control` |
|
||||
| Trigger | `weather_control_available` (weather command appears) |
|
||||
| Display | Overlay |
|
||||
| Title | Weather Magic |
|
||||
| Description | Your mages can influence the weather!<br><br>Rain slows movement, storms disrupt enemies, and clear skies speed your march. |
|
||||
|
||||
### Prisoner Management
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `prisoner_management` |
|
||||
| Trigger | `prisoner_command_issued` (player uses prisoner command) |
|
||||
| Display | Modal |
|
||||
| Title | Prisoners Captured |
|
||||
| Description | You've captured enemy soldiers!<br><br>You can ransom them for gold, recruit them into your army, or execute them as a warning. |
|
||||
|
||||
---
|
||||
|
||||
## Tactical Contextual Tutorials
|
||||
|
||||
Triggered during battles when players encounter spells, terrain, or abilities.
|
||||
|
||||
### Lightning Bolt Spell
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `spell_lightning` |
|
||||
| Trigger | `spell_lightning_available` |
|
||||
| Display | Tooltip |
|
||||
| Title | Lightning Bolt |
|
||||
| Description | Your mage can cast Lightning Bolt!<br><br>This spell strikes a single target for heavy damage. Great for eliminating key enemy units. |
|
||||
|
||||
### Meteor Strike Spell
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `spell_meteor` |
|
||||
| Trigger | `spell_meteor_available` |
|
||||
| Display | Modal |
|
||||
| Title | Meteor Strike |
|
||||
| Description | Meteor is a devastating area spell!<br><br>It takes a turn to cast: first select target, then it lands next turn. Plan ahead! |
|
||||
|
||||
### Holy Wave Spell
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `spell_holywave` |
|
||||
| Trigger | `spell_holywave_available` |
|
||||
| Display | Tooltip |
|
||||
| Title | Holy Wave |
|
||||
| Description | Holy Wave heals your units and damages undead!<br><br>Position your troops carefully to maximize its effect. |
|
||||
|
||||
### Raise Dead Spell
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `spell_raisedead` |
|
||||
| Trigger | `spell_raisedead_available` |
|
||||
| Display | Modal |
|
||||
| Title | Raise Dead |
|
||||
| Description | Dark magic can raise fallen soldiers as undead!<br><br>They fight for you, but beware - they may crumble if your necromancer falls. |
|
||||
|
||||
### Fire Terrain
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `terrain_fire` |
|
||||
| Trigger | `terrain_fire_encountered` (fire damage occurs) |
|
||||
| Display | Tooltip |
|
||||
| Title | Fire Hazard |
|
||||
| Description | Fire spreads across the battlefield!<br><br>Units in burning hexes take damage. Use fire to block enemy routes or avoid it yourself. |
|
||||
|
||||
### Water Crossing
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `terrain_water` |
|
||||
| Trigger | `terrain_water_encountered` (water crossing attempted) |
|
||||
| Display | Tooltip |
|
||||
| Title | Water Crossing |
|
||||
| Description | Units can cross shallow water, but it's risky.<br><br>Crossing takes extra movement and may fail. Some units swim better than others. |
|
||||
|
||||
### Cavalry Charge
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `ability_charge` |
|
||||
| Trigger | `ability_charge_available` |
|
||||
| Display | Overlay |
|
||||
| Title | Cavalry Charge |
|
||||
| Description | Your cavalry can Charge!<br><br>Charging deals bonus damage based on distance traveled. Use open terrain for maximum impact. |
|
||||
|
||||
---
|
||||
|
||||
## Display Modes
|
||||
|
||||
| Mode | Description | Use For |
|
||||
|------|-------------|---------|
|
||||
| **Modal** | Full popup with dimmed background, blocks interaction | Important concepts, multi-paragraph explanations |
|
||||
| **Overlay** | Semi-transparent overlay, can highlight UI elements | Guiding player to interact with specific UI |
|
||||
| **Tooltip** | Small popup near target element | Quick tips, less important info |
|
||||
| **Hint** | Pulsing dot indicator only | Subtle suggestions |
|
||||
| **None** | Invisible, just waits for event | Transition steps |
|
||||
|
||||
---
|
||||
|
||||
## Adding New Tutorials
|
||||
|
||||
1. Add entry to this document
|
||||
2. Update `TutorialContentDefinitions.cs`:
|
||||
- For onboarding: add to `CreateOnboardingSequence()`
|
||||
- For contextual: add to `RegisterStrategicTutorials()` or `RegisterTacticalTutorials()`
|
||||
3. Ensure trigger event exists in `TutorialTriggerRegistry.cs`
|
||||
4. Test the flow
|
||||
|
||||
---
|
||||
|
||||
## Content Guidelines
|
||||
|
||||
- Keep descriptions to 2-3 short paragraphs max
|
||||
- Use `<br><br>` for paragraph breaks (renders as newlines in Unity)
|
||||
- Avoid jargon - explain game terms when first introduced
|
||||
- Be encouraging, not condescending
|
||||
- Focus on "what to do" not exhaustive "how it works"
|
||||
@@ -1,305 +0,0 @@
|
||||
# Actions and Commands Model Usage Analysis
|
||||
|
||||
This document analyzes all actions and commands in `src/main/scala/net/eagle0/eagle/library/actions/impl` to determine which use Scala models vs protobuf models, based on BUILD.bazel dependencies.
|
||||
|
||||
**Legend:**
|
||||
- ✅ **Scala Models Only** - Uses only `//src/main/scala/net/eagle0/eagle/model` dependencies
|
||||
- ❌ **Uses Protobuf** - Has dependencies on `//src/main/protobuf` targets
|
||||
- 🔄 **Partial Conversion** - Conversion attempted but blocked by dependencies
|
||||
|
||||
## Summary
|
||||
|
||||
Based on BUILD.bazel dependency analysis (2025-09-16, updated 2025-09-17):
|
||||
- **Total Commands Analyzed:** 41
|
||||
- **Commands Fully Migrated (No Protobuf):** 41 (100%) ✅
|
||||
- **Commands Still Using Protobuf:** 0 (0%) ✅
|
||||
- **Total Actions Analyzed:** 48
|
||||
- **Actions Fully Migrated (No Protobuf):** 5 (10.4%)
|
||||
- **Actions Partially Migrated:** 19 (39.6%)
|
||||
- **Actions Still Using Protobuf:** 24 (50%)
|
||||
- **Base Classes:** 8 protoless variants available, 6 still use protobuf
|
||||
- **Shared Components:** `ResolvedEagleUnit` migrated to use `Option[BattalionT]` for proper null handling
|
||||
|
||||
## Conversion Insights
|
||||
|
||||
Based on conversion attempt of `ResolveTruceOfferCommand` (see [PR #4379](https://github.com/nolen777/eagle0/pull/4379)):
|
||||
|
||||
### Key Challenges Discovered
|
||||
|
||||
1. **LLM Integration Dependencies**: Commands that use `DiplomacyResolutionLlmRequestGenerator` face challenges because the LLM system still expects protobuf enum types, not Scala model enums.
|
||||
|
||||
2. **Inconsistent Package Naming**: Some files have inconsistent package declarations vs BUILD file locations (e.g., `generated_text_request_generators` in package vs `llm_request_generators` in BUILD).
|
||||
|
||||
3. **Model Constructor Differences**: Scala model constructors (e.g., `TruceOffer`) have different required parameters than their protobuf counterparts, requiring more complex data mapping.
|
||||
|
||||
4. **Type System Complexity**: Union types and type constraints become more complex when mixing protobuf and Scala model types during transition.
|
||||
|
||||
5. **Cascading Dependency Issues**: Converting to `ActionResultC` requires extensive trait dependencies (`ChangedBattalionT`, `ChangedHeroT`, `GeneratedTextRequestT`, etc.) that create complex BUILD dependency graphs, unlike simple protobuf `ActionResult`.
|
||||
|
||||
6. **BUILD Complexity**: Each Scala model conversion requires significantly more BUILD dependencies than protobuf equivalents, making incremental conversion difficult.
|
||||
|
||||
7. **Build Verification Critical**: Any conversion must maintain working build state - even simple commands like `DefendCommand` can break main server build due to dependency cascades.
|
||||
### Successful Conversion Elements
|
||||
|
||||
- ✅ Base class conversion (`SimpleAction` → `ProtolessSimpleAction`)
|
||||
- ✅ Import updates for most Scala model types
|
||||
- ✅ BUILD.bazel dependency updates for core action result types
|
||||
- ✅ Basic type conversions for simple cases
|
||||
|
||||
### Recommended Conversion Strategy
|
||||
|
||||
1. **Architecture-First Approach**: Convert base infrastructure (LLM generators, action result builders) before individual commands
|
||||
2. **Wrapper Pattern**: Use existing `Protoless*ActionWrapper` classes as templates for gradual transition
|
||||
3. **Dependency Analysis**: Map full dependency trees before attempting conversions to avoid cascading build failures
|
||||
4. **Batch Conversions**: Convert related commands together to minimize dependency conflicts
|
||||
5. **Build Verification**: **ALWAYS** verify `//src/main/scala/net/eagle0/eagle:eagle_server` and test suite build before creating PRs
|
||||
|
||||
### Conversion Requirements
|
||||
|
||||
**Before creating any PR:**
|
||||
- ✅ `bazel build //src/main/scala/net/eagle0/eagle:eagle_server` succeeds
|
||||
- ✅ `bazel test //src/test/scala/... --keep_going` passes (or doesn't introduce new failures)
|
||||
- ✅ All BUILD dependencies are correctly specified
|
||||
- ✅ Scalafmt and other linters pass
|
||||
|
||||
---
|
||||
|
||||
## Common Base Classes
|
||||
|
||||
| File | Type | Model Usage | Notes |
|
||||
|------|------|-------------|-------|
|
||||
| Action.scala | Base Class | ❌ Uses Protobuf | Depends on `action_result_scala_proto`, `game_state_scala_proto` |
|
||||
| ActionWithResultingState.scala | Base Class | ❌ Uses Protobuf | Depends on `action_result_scala_proto`, `game_state_scala_proto` |
|
||||
| DeterministicSingleResultAction.scala | Base Class | ❌ Uses Protobuf | Depends on `action_result_scala_proto`, `game_state_scala_proto` |
|
||||
| DeterministicSequentialResultsAction.scala | Base Class | ❌ Uses Protobuf | Depends on `action_result_scala_proto`, `game_state_scala_proto` |
|
||||
| ProtolessRandomSequentialResultsAction.scala | Base Class | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model/action_result` |
|
||||
| ProtolessRandomSimpleAction.scala | Base Class | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model/action_result` |
|
||||
| ProtolessSequentialResultsAction.scala | Base Class | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model/action_result` |
|
||||
| ProtolessSimpleAction.scala | Base Class | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model/action_result` |
|
||||
| RandomSequentialResultsAction.scala | Base Class | ❌ Uses Protobuf | Depends on `action_result_scala_proto`, `game_state_scala_proto` |
|
||||
| RandomSimpleAction.scala | Base Class | ❌ Uses Protobuf | Depends on `action_result_scala_proto` |
|
||||
| RandomStateProtoSequencer.scala | Sequencer | ❌ Uses Protobuf | Bridge class, depends on both protobuf and Scala models |
|
||||
| RandomStateTSequencer.scala | Sequencer | ❌ Uses Protobuf | Bridge class, depends on both protobuf and Scala models |
|
||||
| SimpleAction.scala | Base Class | ❌ Uses Protobuf | Depends on `action_result_scala_proto` |
|
||||
| VigorXPApplier.scala | Utility | ❌ Uses Protobuf | Depends on `action_result_scala_proto` |
|
||||
|
||||
---
|
||||
|
||||
## Actions
|
||||
|
||||
### ✅ Fully Migrated Actions (No Protobuf Dependencies)
|
||||
|
||||
These actions have been successfully migrated to use Scala models only:
|
||||
|
||||
| File | Base Class | Notes |
|
||||
|------|------------|-------|
|
||||
| HeroBackstoryUpdateAction.scala | ProtolessSequentialResultsAction | Processes hero backstory updates with LLM integration |
|
||||
| ProvinceConqueredAction.scala | ProtolessSimpleAction | Uses component-based design (gameId, currentRoundId, currentDate, Scala models) |
|
||||
| ProvinceHeldAction.scala | ProtolessSimpleAction | Uses specific components (gameId, currentRoundId, defendingProvince, etc.) instead of full GameState |
|
||||
| UnaffiliatedHeroAppearedAction.scala | ProtolessSimpleAction | Handles unaffiliated hero appearance with name generation |
|
||||
| WithdrawnArmiesReturnHomeAction.scala | ProtolessSequentialResultsAction | Manages army withdrawal and return mechanics |
|
||||
|
||||
### 🔄 Actions Partially Migrated (Using Protoless Base Classes)
|
||||
|
||||
These actions use protoless base classes but still have some protobuf dependencies:
|
||||
|
||||
| File | Model Usage | Notes |
|
||||
|------|-------------|-------|
|
||||
| CheckForFactionChangesAction.scala | ProtolessSequentialResultsAction | Still has some protobuf dependencies |
|
||||
| CheckForFailedQuestsAction.scala | ProtolessSequentialResultsAction | Depends on `unaffiliated_hero_quest_scala_proto` |
|
||||
| CheckForFulfilledQuestsAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
|
||||
| EndAttackDecisionPhaseAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
|
||||
| EndBattleAftermathPhaseAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
|
||||
| EndFreeForAllDecisionPhaseAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
|
||||
| EndPlayerCommandsPhaseAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
|
||||
| EndUnaffiliatedHeroActionsPhaseAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
|
||||
| EndVassalCommandsPhaseAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
|
||||
| FreeForAllDrawAction.scala | ProtolessSimpleAction | Depends on multiple protobuf targets |
|
||||
| FriendlyMoveAction.scala | ProtolessSimpleAction | Depends on multiple protobuf targets |
|
||||
| PerformUncontestedConquestAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
|
||||
| ProvinceConqueredAction.scala | ProtolessSimpleAction | **CONVERTED** - Uses specific components (gameId, currentRoundId, currentDate, Scala models) |
|
||||
| SafePassageArmiesProceedAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
|
||||
| ShipmentArrivedAction.scala | ProtolessSimpleAction | Depends on multiple protobuf targets |
|
||||
| TruceTurnBackPhaseAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
|
||||
| UnaffiliatedHeroRejoinedAction.scala | ProtolessSimpleAction | Depends on multiple protobuf targets |
|
||||
| WonFreeForAllAction.scala | ProtolessSimpleAction | Depends on multiple protobuf targets |
|
||||
|
||||
### ❌ Actions Still Using Protobuf (Not Yet Using Protoless Base Classes)
|
||||
|
||||
| File | Notes |
|
||||
|------|-------|
|
||||
| ChronicleEventGenerator.scala | Depends on multiple protobuf targets |
|
||||
| EndBattleRequestPhaseAction.scala | Depends on `diplomacy_offer_status_scala_proto` |
|
||||
| EndBattleResolutionPhaseAction.scala | Depends on multiple protobuf targets |
|
||||
| EndDefenseDecisionPhaseAction.scala | Depends on multiple protobuf targets |
|
||||
| EndDiplomacyResolutionPhaseAction.scala | Depends on multiple protobuf targets |
|
||||
| EndFreeForAllBattleRequestPhaseAction.scala | Depends on multiple protobuf targets |
|
||||
| EndFreeForAllBattleResolutionPhaseAction.scala | Depends on multiple protobuf targets |
|
||||
| EndHandleRiotsPhaseAction.scala | Depends on multiple protobuf targets |
|
||||
| EndPleaseRecruitMePhaseAction.scala | Depends on multiple protobuf targets |
|
||||
| EndProvinceMoveResolutionPhaseAction.scala | Depends on multiple protobuf targets |
|
||||
| NewRoundAction.scala | Depends on multiple protobuf targets |
|
||||
| NewYearAction.scala | Depends on multiple protobuf targets |
|
||||
| PerformFoodConsumptionPhaseAction.scala | Depends on multiple protobuf targets |
|
||||
| PerformForcedTurnBackAction.scala | Depends on multiple protobuf targets |
|
||||
| PerformHeroDeparturesAction.scala | Depends on multiple protobuf targets |
|
||||
| PerformHostileArmySetupAction.scala | Depends on multiple protobuf targets |
|
||||
| PerformProvinceEventsAction.scala | Depends on `province_event_scala_proto` |
|
||||
| PerformProvinceMoveResolutionAction.scala | Depends on multiple protobuf targets |
|
||||
| PerformReconResolutionAction.scala | Depends on multiple protobuf targets |
|
||||
| PerformUnaffiliatedHeroesAction.scala | Depends on `unaffiliated_hero_quest_scala_proto` |
|
||||
| PerformVassalCommandsPhaseAction.scala | Depends on multiple protobuf targets |
|
||||
| PerformVassalDefenseDecisionsAction.scala | Depends on multiple protobuf targets |
|
||||
| PrisonerEscapeAction.scala | Depends on `game_state_scala_proto` |
|
||||
| PrisonerExchangeAction.scala | Depends on multiple protobuf targets |
|
||||
| RequestBattlesAction.scala | Depends on multiple protobuf targets |
|
||||
| RequestFreeForAllBattlesAction.scala | Depends on multiple protobuf targets |
|
||||
| ResolveBattleAction.scala | Depends on `shardok_internal_interface_scala_grpc` |
|
||||
| UnaffiliatedHeroMovedAction.scala | Depends on multiple protobuf targets |
|
||||
| UnaffiliatedHeroesChangedAction.scala | Depends on multiple protobuf targets |
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
✅ **ALL COMMANDS FULLY MIGRATED** (100% - 41/41 commands)
|
||||
|
||||
All 41 commands in the codebase have been successfully migrated to use Scala models only, with no protobuf dependencies. This includes:
|
||||
|
||||
- **Simple Actions**: Use `ProtolessSimpleAction` base class
|
||||
- **Random Actions**: Use `ProtolessRandomSimpleAction` base class
|
||||
- **Complex Domain Models**: Successfully integrated with LLM systems, diplomacy, quest fulfillment, and state management
|
||||
- **Complete Type Safety**: All commands now use type-safe Scala domain models
|
||||
|
||||
**Key Migration Achievements:**
|
||||
- ✅ All military commands (ArmTroops, Train, Organize, etc.)
|
||||
- ✅ All diplomacy commands (Resolve Alliance/Truce/Ransom offers, etc.)
|
||||
- ✅ All LLM-integrated commands (backstory generation, diplomacy resolution)
|
||||
- ✅ All quest and event commands
|
||||
- ✅ Final remaining command (FreeForAllDecisionCommand) migrated
|
||||
|
||||
---
|
||||
|
||||
## Diplomacy Helpers
|
||||
|
||||
All diplomacy helpers use **Scala models only**:
|
||||
|
||||
| File | Model Usage | Notes |
|
||||
|------|-------------|-------|
|
||||
| AllianceResolutionHelpers.scala | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model` only |
|
||||
| BreakAllianceResolutionHelpers.scala | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model` only |
|
||||
| InvitationResolutionHelpers.scala | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model` only |
|
||||
| RansomResolutionHelpers.scala | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model` only |
|
||||
| TruceResolutionHelpers.scala | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model` only |
|
||||
|
||||
---
|
||||
|
||||
## Migration Priority Analysis
|
||||
|
||||
Based on the BUILD.bazel dependency analysis, here are the key findings and recommendations:
|
||||
|
||||
### 🎯 High Impact Migration Targets
|
||||
|
||||
**Core Dependencies Blocking Multiple Commands:**
|
||||
|
||||
1. **`action_result_scala_proto`** - Used by 12+ commands
|
||||
- Blocks: `DefendCommand`, `FreeForAllDecisionCommand`, diplomacy resolvers
|
||||
- Impact: Would unlock many command migrations
|
||||
|
||||
2. **`available_command_scala_proto` / `selected_command_scala_proto`** - Used by 10+ commands
|
||||
- Blocks: All UI-interactive commands
|
||||
- Impact: Would enable client-server interaction model migration
|
||||
|
||||
3. **`game_state_scala_proto`** - Used by 8+ commands
|
||||
- Blocks: Complex state-dependent commands
|
||||
- Impact: Core state representation migration
|
||||
|
||||
### 📊 Migration Tiers by Complexity
|
||||
|
||||
**Tier 1 - Quick Wins (2 commands):**
|
||||
- `ArmTroopsCommand` - Only `battalion_type` dependency
|
||||
- `TrainCommand` - Only `battalion_type` dependency
|
||||
- **Effort:** Low, **Impact:** Demonstrates battalion model usage
|
||||
|
||||
**Tier 2 - API Layer (5 commands):**
|
||||
- Commands blocked by `available_command`/`selected_command`
|
||||
- **Effort:** Medium, **Impact:** High (enables UI interaction models)
|
||||
|
||||
**Tier 3 - Diplomacy Suite (6 commands):**
|
||||
- All `Resolve*Command` diplomacy commands
|
||||
- **Effort:** High, **Impact:** High (complete diplomacy model migration)
|
||||
- **Strategy:** Migrate as a group after diplomacy models are ready
|
||||
|
||||
### 🏆 Success Metrics
|
||||
|
||||
**Current Status:**
|
||||
- ✅ **100% of commands fully migrated** (41/41) 🎉
|
||||
- ✅ **All diplomacy helpers use Scala models**
|
||||
- ✅ **All protoless base classes available**
|
||||
- ✅ **ALL command migration completed**
|
||||
|
||||
**Completed Milestones:**
|
||||
- ✅ **70% target:** Migrate Tier 1 + some Tier 2 commands **COMPLETED**
|
||||
- ✅ **80% target:** Continue with remaining non-diplomacy commands **COMPLETED**
|
||||
- ✅ **85% target:** Complete API layer migration **COMPLETED**
|
||||
- ✅ **95% target:** Complete diplomacy migration **COMPLETED**
|
||||
- ✅ **100% target:** Migrate final remaining command (FreeForAllDecisionCommand) **COMPLETED**
|
||||
|
||||
### 🎯 Action Migration Progress
|
||||
|
||||
**Migration Statistics:**
|
||||
- 5/48 Actions fully migrated (10.4%)
|
||||
- 20/48 Actions using protoless base classes but with protobuf dependencies (41.7%)
|
||||
- 24/48 Actions still fully on protobuf (50%)
|
||||
|
||||
**Successfully Migrated Actions:**
|
||||
1. **HeroBackstoryUpdateAction** - LLM integration for hero backstories
|
||||
2. **ProvinceConqueredAction** - Component-based design with prisoner handling and province conquest
|
||||
3. **ProvinceHeldAction** - Component-based design pattern (gameId, currentRoundId, specific models)
|
||||
4. **UnaffiliatedHeroAppearedAction** - Hero appearance with name generation
|
||||
5. **WithdrawnArmiesReturnHomeAction** - Army withdrawal mechanics
|
||||
|
||||
**Recent Migration Updates (2025-09-17):**
|
||||
- **ResolvedEagleUnit** - Changed `battalion: BattalionT` to `battalion: Option[BattalionT]`
|
||||
- Properly handles units without battalions (battalion ID -1)
|
||||
- Updated `ShardokInterfaceGrpcClient` to check for `defaultBattalionId` and use `None`
|
||||
- Updated `ResolveBattleAction`, `ProvinceConqueredAction`, `RequestBattlesAction`
|
||||
- All tests updated to handle optional battalions
|
||||
|
||||
**Key Migration Patterns:**
|
||||
- ✅ Use specific components instead of full GameState (see ProvinceHeldAction, ProvinceConqueredAction)
|
||||
- ✅ Convert protobuf models to Scala models at Action boundaries
|
||||
- ✅ Update BUILD.bazel to remove protobuf dependencies
|
||||
- ✅ Update all call sites and tests
|
||||
- ✅ Use `Option[T]` for optional fields instead of special sentinel values (e.g., battalion ID -1)
|
||||
|
||||
**Next Migration Candidates (Simple Actions with Protoless Base):**
|
||||
1. **FreeForAllDrawAction** - Already uses ProtolessSimpleAction
|
||||
2. **FriendlyMoveAction** - Already uses ProtolessSimpleAction
|
||||
3. **ShipmentArrivedAction** - Already uses ProtolessSimpleAction
|
||||
4. **WonFreeForAllAction** - Already uses ProtolessSimpleAction
|
||||
5. **ProvinceConqueredAction** - Already uses ProtolessSimpleAction, only needs `common_unit` migration
|
||||
|
||||
### 🔄 Conversion Strategy Updates
|
||||
|
||||
**Revised Approach Based on Analysis:**
|
||||
|
||||
1. **Focus on Core Dependencies First**
|
||||
- Migrate `battalion_type` model (unlocks 2 commands immediately)
|
||||
- Migrate `action_result` model (unlocks 12+ commands)
|
||||
- Migrate `available_command`/`selected_command` (unlocks UI layer)
|
||||
|
||||
2. **Leverage Existing Success**
|
||||
- 77.5% of commands already fully migrated
|
||||
- Use migrated commands as reference implementations
|
||||
- Diplomacy helpers prove complex business logic can work with Scala models
|
||||
|
||||
3. **Group Related Migrations**
|
||||
- Military commands: `ArmTroopsCommand`, `TrainCommand`, `OrganizeTroopsCommand`
|
||||
- UI commands: All using `available_command`/`selected_command`
|
||||
- Diplomacy commands: All `Resolve*Command` variants
|
||||
|
||||
---
|
||||
|
||||
*Updated on 2025-09-17 - Analysis based on BUILD.bazel dependencies and code review*
|
||||
*Latest update: ResolvedEagleUnit migrated to use Option[BattalionT] for proper battalion handling*
|
||||
@@ -1,310 +0,0 @@
|
||||
# Scala 3 Migration: Reflection Issues Found
|
||||
|
||||
This document catalogs all reflection-related problems discovered during the Scala 2.13.16 → Scala 3.7.2 migration of the Eagle0 codebase.
|
||||
|
||||
## Summary
|
||||
|
||||
The migration revealed several categories of reflection issues that needed to be addressed for Scala 3 compatibility:
|
||||
|
||||
1. **Scala 2 Runtime Reflection API** - No longer available in Scala 3
|
||||
2. **Settings System Reflection** - Custom reflection for loading settings singletons
|
||||
3. **json4s Automatic Case Class Extraction** - Uses reflection that fails with Scala 3 metaprogramming classes
|
||||
4. **ScalaTest Exception Handling** - Syntax changes affecting exception variable binding
|
||||
|
||||
## 1. Scala 2 Runtime Reflection (FIXED)
|
||||
|
||||
### Issue
|
||||
Tests using `scala.reflect.runtime.universe` fail because this reflection API doesn't exist in Scala 3.
|
||||
|
||||
### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/test/scala/net/eagle0/eagle/library/actions/types/ActionResultTypesTest.scala`
|
||||
|
||||
### Error
|
||||
```scala
|
||||
import scala.reflect.runtime.universe // Not available in Scala 3
|
||||
```
|
||||
|
||||
### Solution Applied
|
||||
**Deleted the test entirely** as it was redundant. The test was verifying that auto-generated Scala objects (created by Bazel from proto enum values) matched their source proto values - something already guaranteed by the build system. Since the objects are generated directly from the proto definitions, this test provided no value.
|
||||
|
||||
**Files deleted:**
|
||||
- `src/test/scala/net/eagle0/eagle/library/actions/types/ActionResultTypesTest.scala`
|
||||
|
||||
## 2. Settings System Reflection (FIXED)
|
||||
|
||||
### Issue
|
||||
Custom `SettingsLoader` class used reflection to access Scala object singletons, but the reflection pattern changed between Scala 2 and Scala 3.
|
||||
|
||||
### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/library/settings/loaders/SettingsLoader.scala`
|
||||
|
||||
### Error
|
||||
```
|
||||
java.lang.NoSuchMethodException: net.eagle0.eagle.library.settings.ApprehendOutlawVigorCost$.MODULE$
|
||||
```
|
||||
|
||||
### Root Cause
|
||||
In Scala 2, singleton objects are accessed via `ClassName$.MODULE$()`, but in Scala 3, they're accessed directly via `ClassName$` field. Additionally, `scala.reflect.runtime.universe` is not available in Scala 3.
|
||||
|
||||
### Solution Applied
|
||||
**Completely eliminated reflection** by auto-generating the entire `SettingsLoader.scala` file from BUILD.bazel definitions:
|
||||
|
||||
1. **Created generator**: `src/main/go/net/eagle0/build/settings_loader_generator/settings_loader_generator.go` - parses BUILD.bazel and generates complete SettingsLoader.scala with pattern matching for all 272 settings
|
||||
|
||||
2. **Added genrule**: In `src/main/scala/net/eagle0/eagle/library/settings/loaders/BUILD.bazel`:
|
||||
```python
|
||||
genrule(
|
||||
name = "settings_loader_src",
|
||||
srcs = ["//src/main/scala/net/eagle0/eagle/library/settings:BUILD.bazel"],
|
||||
outs = ["SettingsLoader.scala"],
|
||||
cmd = "$(location //src/main/go/net/eagle0/build/settings_loader_generator) $(location //src/main/scala/net/eagle0/eagle/library/settings:BUILD.bazel) > $@",
|
||||
tools = ["//src/main/go/net/eagle0/build/settings_loader_generator"],
|
||||
)
|
||||
```
|
||||
|
||||
3. **Result**: SettingsLoader now uses compile-time pattern matching instead of reflection:
|
||||
```scala
|
||||
private def settingObjectForKey(key: String): Any = key match {
|
||||
case "ActionVigorCost" => ActionVigorCost
|
||||
case "BaseFoodBuyPrice" => BaseFoodBuyPrice
|
||||
// ... all 272 settings auto-generated
|
||||
case _ => throw NoSuchSettingException(key)
|
||||
}
|
||||
```
|
||||
|
||||
### Benefits
|
||||
- **No reflection** - Completely Scala 3 compatible
|
||||
- **Maintainable** - New settings automatically included when added to BUILD.bazel
|
||||
- **Performance** - Pattern matching is faster than reflection
|
||||
- **Type-safe** - Compile-time checking of all settings
|
||||
|
||||
## 3. json4s Reflection Issues (MULTIPLE LOCATIONS)
|
||||
|
||||
### 3.1 EagleServiceImpl JSON Serialization (FIXED)
|
||||
|
||||
#### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/service/EagleServiceImpl.scala`
|
||||
|
||||
#### Error
|
||||
```
|
||||
java.lang.NoClassDefFoundError: scala/quoted/staging/package$
|
||||
```
|
||||
|
||||
#### Root Cause
|
||||
json4s automatic case class serialization uses reflection that tries to access Scala 3 metaprogramming classes (`scala.quoted.staging.package$`) which aren't available at runtime.
|
||||
|
||||
#### Solution Applied
|
||||
Replaced automatic json4s serialization with ScalaPB's built-in JSON support:
|
||||
|
||||
```scala
|
||||
// Old (reflection-based):
|
||||
// implicit val formats: DefaultFormats.type = DefaultFormats
|
||||
// write(actionResultView)
|
||||
|
||||
// New (ScalaPB JSON support):
|
||||
import scalapb.json4s.JsonFormat
|
||||
JsonFormat.toJsonString(actionResultView.toProto)
|
||||
```
|
||||
|
||||
### 3.2 ShardokMapInfo JSON Parsing (FIXED)
|
||||
|
||||
#### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/library/util/ShardokMapInfo.scala` (Line 44)
|
||||
|
||||
#### Error
|
||||
```
|
||||
java.lang.NoClassDefFoundError: scala/quoted/staging/package$
|
||||
at org.json4s.reflect.ScalaSigReader$.readConstructor(ScalaSigReader.scala:42)
|
||||
```
|
||||
|
||||
#### Root Cause
|
||||
The line `val extracted = parsedJson.extract[List[ShardokMapInfo]]` uses json4s automatic case class extraction which relies on reflection.
|
||||
|
||||
#### Solution Applied
|
||||
Replaced automatic extraction with manual JSON parsing:
|
||||
|
||||
```scala
|
||||
// OLD (reflection-based):
|
||||
val extracted = parsedJson.extract[List[ShardokMapInfo]]
|
||||
|
||||
// NEW (manual parsing, no reflection):
|
||||
val extracted = parsedJson match {
|
||||
case JArray(items) => items.map { item =>
|
||||
val name = (item \ "name").extract[String]
|
||||
val castleCount = (item \ "castleCount").extract[Int]
|
||||
val positions = (item \ "positions").extract[Map[Int, Int]]
|
||||
ShardokMapInfo(name, castleCount, positions)
|
||||
}
|
||||
case _ => throw new Exception("Expected JSON array for map info")
|
||||
}
|
||||
```
|
||||
|
||||
#### Testing
|
||||
The fix was verified - `attack_command_chooser_test` now passes successfully.
|
||||
|
||||
### 3.3 HeroNameFetcher JSON Parsing (FIXED)
|
||||
|
||||
#### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/library/util/hero_name_fetcher/HeroNameFetcher.scala`
|
||||
|
||||
#### Issue
|
||||
Case class extraction `parsedJson.extract[ResponseBody]` uses reflection that may fail in Scala 3.
|
||||
|
||||
#### Solution Applied
|
||||
Replaced automatic case class extraction with manual JSON parsing:
|
||||
|
||||
```scala
|
||||
// OLD (reflection-based):
|
||||
val parsedJson = json.parse(src.getLines().mkString)
|
||||
parsedJson.extract[ResponseBody]
|
||||
|
||||
// NEW (manual parsing, no reflection):
|
||||
parsedJson \ "names" match {
|
||||
case JArray(nameArray) =>
|
||||
nameArray.map { nameObj =>
|
||||
val id = (nameObj \ "id").extract[String]
|
||||
val name = (nameObj \ "name").extract[String]
|
||||
NameResponse(id, name)
|
||||
}.toVector
|
||||
case _ => throw new Exception("Expected 'names' array in response")
|
||||
}
|
||||
```
|
||||
|
||||
#### Testing
|
||||
The fix was verified - HeroNameFetcher now builds successfully without reflection.
|
||||
|
||||
### 3.4 Other json4s Usage Analysis
|
||||
|
||||
#### Files with json4s extraction:
|
||||
- **✅ SAFE**: OpenAI/Claude Services - Only extract simple types (`String`, `Int`) - no reflection
|
||||
- **✅ FIXED**: `HeroNameFetcher.scala` - Replaced `extract[ResponseBody]` with manual parsing (no reflection)
|
||||
- **⚠️ POTENTIAL ISSUES** (not currently causing failures but should be monitored):
|
||||
- `JsonUtils.scala`: `extract[Map[String, Vector[String]]]` - complex type extraction
|
||||
- `HexMapJsonUtils.scala`: `extract[List[JObject]]` - may be problematic
|
||||
|
||||
#### Recommendation
|
||||
Apply the same manual parsing pattern to remaining case class extractions if they cause runtime failures during Scala 3 migration.
|
||||
|
||||
## 4. ScalaTest Exception Handling Syntax (FIXED)
|
||||
|
||||
### Issue
|
||||
Scala 3 changed how exception variables are bound in ScalaTest's `the[Exception] thrownBy {...}` construct.
|
||||
|
||||
### Files Affected
|
||||
**70+ test files** across the codebase using exception testing patterns.
|
||||
|
||||
### Error Pattern
|
||||
```
|
||||
Not found: ex
|
||||
```
|
||||
|
||||
### Root Cause
|
||||
In Scala 2: `the[Exception] thrownBy { ... }` automatically creates an `ex` variable.
|
||||
In Scala 3: The exception variable must be explicitly bound.
|
||||
|
||||
### Solution Applied
|
||||
Added explicit variable binding across all affected test files:
|
||||
|
||||
```scala
|
||||
// Old Scala 2 syntax:
|
||||
the[EagleCommandException] thrownBy {
|
||||
// test code
|
||||
}
|
||||
ex.getMessage shouldBe "expected message"
|
||||
|
||||
// New Scala 3 syntax:
|
||||
val ex = the[EagleCommandException] thrownBy {
|
||||
// test code
|
||||
}
|
||||
ex.getMessage shouldBe "expected message"
|
||||
```
|
||||
|
||||
### Script Used
|
||||
Created and ran a systematic fix script that processed 70+ files:
|
||||
|
||||
```bash
|
||||
# Pattern to find and fix exception handling
|
||||
find . -name "*.scala" -exec sed -i '' 's/the\[\([^]]*\)\] thrownBy {/val ex = the[\1] thrownBy {/g' {} \;
|
||||
```
|
||||
|
||||
## 5. ScalaTest Import Changes (FIXED)
|
||||
|
||||
### Issue
|
||||
Scala 3 requires different imports for ScalaTest matchers.
|
||||
|
||||
### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/test/scala/net/eagle0/eagle/library/actions/impl/command/DeclineQuestCommandTest.scala`
|
||||
|
||||
### Error
|
||||
```
|
||||
value convertToAnyShouldWrapper is not a member of object org.scalatest.matchers.should.Matchers
|
||||
```
|
||||
|
||||
### Solution Applied
|
||||
Changed from specific imports to wildcard import:
|
||||
|
||||
```scala
|
||||
// Old:
|
||||
import org.scalatest.matchers.should.Matchers.{convertToAnyShouldWrapper, the}
|
||||
|
||||
// New:
|
||||
import org.scalatest.matchers.should.Matchers.*
|
||||
```
|
||||
|
||||
## 6. Mock Framework Issues (FIXED)
|
||||
|
||||
### Issue
|
||||
ScalaMock had type inference issues with Scala 3 for classes with constructor parameters.
|
||||
|
||||
### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/test/scala/net/eagle0/eagle/library/EngineImplTest.scala`
|
||||
|
||||
### Error
|
||||
```
|
||||
Found: Vector
|
||||
Required: Vector[net.eagle0.eagle.library.util.hero_generator.hero_with_name.HeroWithName]
|
||||
```
|
||||
|
||||
### Root Cause
|
||||
Mock framework couldn't properly infer types for `mock[HeroGenerator]` where `HeroGenerator` has constructor parameters.
|
||||
|
||||
### Solution Applied
|
||||
The user updated to a newer ScalaMock version that fixed this issue, plus added some missing Bazel dependencies:
|
||||
|
||||
```scala
|
||||
// Also needed to add missing dependency:
|
||||
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution"
|
||||
```
|
||||
|
||||
## Migration Status
|
||||
|
||||
### ✅ COMPLETED
|
||||
- [x] Scala 2 runtime reflection removal
|
||||
- [x] Settings system reflection compatibility
|
||||
- [x] EagleServiceImpl json4s → ScalaPB JSON
|
||||
- [x] ScalaTest exception handling syntax (70+ files)
|
||||
- [x] ScalaTest import changes
|
||||
- [x] Mock framework issues (via ScalaMock update)
|
||||
- [x] All test compilation issues resolved
|
||||
|
||||
### ⚠️ REMAINING
|
||||
- [ ] **Potential json4s case class extractions** - May cause runtime failures (JsonUtils, HexMapJsonUtils) - currently no test failures reported
|
||||
|
||||
### 📊 PROGRESS
|
||||
- **Tests passing**: All identified runtime failures resolved
|
||||
- **Build failures**: 0 (all tests now compile)
|
||||
- **Runtime failures**: 0 (critical ShardokMapInfo issue resolved)
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **✅ COMPLETED**: ShardokMapInfo json4s reflection issue resolved with manual parsing
|
||||
2. **Monitor remaining json4s usage**: Watch for runtime failures in HeroNameFetcher, JsonUtils, and HexMapJsonUtils during full Scala 3 migration
|
||||
3. **Consider ScalaPB for new JSON needs**: For new functionality, prefer ScalaPB's JSON support to avoid reflection entirely
|
||||
4. **Apply manual parsing pattern**: If other json4s case class extractions cause runtime failures, use the same manual parsing approach demonstrated in ShardokMapInfo
|
||||
|
||||
## Key Learnings
|
||||
|
||||
- **Scala 3 reflection changes**: Major differences in singleton object access patterns
|
||||
- **json4s compatibility**: Automatic case class extraction doesn't work well with Scala 3 metaprogramming
|
||||
- **ScalaPB advantage**: Using ScalaPB's JSON support avoids reflection issues entirely
|
||||
- **Systematic approach**: Many issues followed patterns that could be fixed with scripts across multiple files
|
||||
Binary file not shown.
Vendored
-8
@@ -1,8 +0,0 @@
|
||||
load("@build_bazel_rules_apple//apple:apple.bzl", "apple_dynamic_framework_import")
|
||||
|
||||
# Import pre-built Sparkle framework
|
||||
apple_dynamic_framework_import(
|
||||
name = "Sparkle",
|
||||
framework_imports = glob(["Sparkle.framework/**"]),
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
@@ -9,9 +9,6 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/config v1.28.10
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.51
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
google.golang.org/grpc v1.68.0
|
||||
google.golang.org/protobuf v1.36.3
|
||||
)
|
||||
|
||||
|
||||
@@ -34,18 +34,12 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.33.6 h1:VwhTrsTuVn52an4mXx29PqRzs2Dv
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.6/go.mod h1:+8h7PZb3yY5ftmVLD7ocEoE98hdc8PoKS0H3wfx1dlc=
|
||||
github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
|
||||
github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0=
|
||||
google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA=
|
||||
google.golang.org/protobuf v1.26.0-rc.1 h1:7QnIQpGRHE5RnLKnESfDoxm2dTapTZua5a0kS0A+VXQ=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
|
||||
|
||||
+167
-334
@@ -1,10 +1,9 @@
|
||||
{
|
||||
"__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL",
|
||||
"__INPUT_ARTIFACTS_HASH": -2049857450,
|
||||
"__RESOLVED_ARTIFACTS_HASH": -1728186926,
|
||||
"__INPUT_ARTIFACTS_HASH": 644967262,
|
||||
"__RESOLVED_ARTIFACTS_HASH": -595552834,
|
||||
"conflict_resolution": {
|
||||
"com.google.guava:failureaccess:1.0.1": "com.google.guava:failureaccess:1.0.2",
|
||||
"com.squareup.okio:okio:2.10.0": "com.squareup.okio:okio:3.6.0",
|
||||
"io.netty:netty-buffer:4.1.110.Final": "io.netty:netty-buffer:4.1.112.Final",
|
||||
"io.netty:netty-codec-http2:4.1.110.Final": "io.netty:netty-codec-http2:4.1.112.Final",
|
||||
"io.netty:netty-codec-http:4.1.110.Final": "io.netty:netty-codec-http:4.1.112.Final",
|
||||
@@ -15,7 +14,8 @@
|
||||
"io.netty:netty-transport-native-unix-common:4.1.110.Final": "io.netty:netty-transport-native-unix-common:4.1.112.Final",
|
||||
"io.netty:netty-transport:4.1.110.Final": "io.netty:netty-transport:4.1.112.Final",
|
||||
"io.opencensus:opencensus-api:0.31.0": "io.opencensus:opencensus-api:0.31.1",
|
||||
"org.checkerframework:checker-qual:3.12.0": "org.checkerframework:checker-qual:3.43.0"
|
||||
"org.checkerframework:checker-qual:3.12.0": "org.checkerframework:checker-qual:3.43.0",
|
||||
"org.scala-lang:scala-library:2.13.14": "org.scala-lang:scala-library:2.13.15"
|
||||
},
|
||||
"artifacts": {
|
||||
"com.amazonaws:aws-lambda-java-core": {
|
||||
@@ -48,12 +48,6 @@
|
||||
},
|
||||
"version": "2.12.7"
|
||||
},
|
||||
"com.github.stephenc.jcip:jcip-annotations": {
|
||||
"shasums": {
|
||||
"jar": "4fccff8382aafc589962c4edb262f6aa595e34f1e11e61057d1c6a96e8fc7323"
|
||||
},
|
||||
"version": "1.0-1"
|
||||
},
|
||||
"com.google.android:annotations": {
|
||||
"shasums": {
|
||||
"jar": "ba734e1e84c09d615af6a09d33034b4f0442f8772dec120efb376d86a565ae15"
|
||||
@@ -162,24 +156,6 @@
|
||||
},
|
||||
"version": "1.4.2"
|
||||
},
|
||||
"com.nimbusds:nimbus-jose-jwt": {
|
||||
"shasums": {
|
||||
"jar": "12ae4a3a260095d7aeba2adea7ae396e8b9570db8b7b409e09a824c219cc0444"
|
||||
},
|
||||
"version": "9.37.3"
|
||||
},
|
||||
"com.squareup.okhttp3:okhttp": {
|
||||
"shasums": {
|
||||
"jar": "b1050081b14bb7a3a7e55a4d3ef01b5dcfabc453b4573a4fc019767191d5f4e0"
|
||||
},
|
||||
"version": "4.12.0"
|
||||
},
|
||||
"com.squareup.okhttp3:okhttp-sse": {
|
||||
"shasums": {
|
||||
"jar": "bff4fbcaef7aac2d910d4ff46dafaa4e6d15da127df6bac97216da46943a7d4c"
|
||||
},
|
||||
"version": "4.12.0"
|
||||
},
|
||||
"com.squareup.okhttp:okhttp": {
|
||||
"shasums": {
|
||||
"jar": "88ac9fd1bb51f82bcc664cc1eb9c225c90dc4389d660231b4cc737bebfe7d0aa"
|
||||
@@ -188,39 +164,27 @@
|
||||
},
|
||||
"com.squareup.okio:okio": {
|
||||
"shasums": {
|
||||
"jar": "8e63292e5c53bb93c4a6b0c213e79f15990fed250c1340f1c343880e1c9c39b5"
|
||||
"jar": "a27f091d34aa452e37227e2cfa85809f29012a8ef2501a9b5a125a978e4fcbc1"
|
||||
},
|
||||
"version": "3.6.0"
|
||||
"version": "2.10.0"
|
||||
},
|
||||
"com.squareup.okio:okio-jvm": {
|
||||
"com.thesamet.scalapb:compilerplugin_2.13": {
|
||||
"shasums": {
|
||||
"jar": "67543f0736fc422ae927ed0e504b98bc5e269fda0d3500579337cb713da28412"
|
||||
},
|
||||
"version": "3.6.0"
|
||||
},
|
||||
"com.thesamet.scalapb:compilerplugin_3": {
|
||||
"shasums": {
|
||||
"jar": "e7d7156269fc23cbb539eea60f07c3230aa05a726434fc942b040495567f0a2d"
|
||||
"jar": "218640423ba8156f994d6d700ef960d65025f79a5918070c0898213f4384df1f"
|
||||
},
|
||||
"version": "1.0.0-alpha.1"
|
||||
},
|
||||
"com.thesamet.scalapb:lenses_3": {
|
||||
"com.thesamet.scalapb:lenses_2.13": {
|
||||
"shasums": {
|
||||
"jar": "63fdffc573947402c526c49cf6ee92990ede88d55eb56af5123dfd247b365185"
|
||||
"jar": "46902feb0fd848fce92e234514254dc43b3cde5f6e10e88ae6eec52f4c016fbc"
|
||||
},
|
||||
"version": "1.0.0-alpha.1"
|
||||
},
|
||||
"com.thesamet.scalapb:protoc-bridge_2.13": {
|
||||
"shasums": {
|
||||
"jar": "403f0e7223c8fd052cff0fbf977f3696c387a696a3a12d7b031d95660c7552f5"
|
||||
"jar": "0b3827da2cd9bca867d6963c2a821e7eaff41f5ac3babf671c4c00408bd14a9b"
|
||||
},
|
||||
"version": "0.9.7"
|
||||
},
|
||||
"com.thesamet.scalapb:protoc-bridge_3": {
|
||||
"shasums": {
|
||||
"jar": "e7e2f1862f54076b6870bd034a7c16aae7b88cfee3d00b69dbb6b1175108560c"
|
||||
},
|
||||
"version": "0.9.9"
|
||||
"version": "0.9.8"
|
||||
},
|
||||
"com.thesamet.scalapb:protoc-gen_2.13": {
|
||||
"shasums": {
|
||||
@@ -228,24 +192,30 @@
|
||||
},
|
||||
"version": "0.9.7"
|
||||
},
|
||||
"com.thesamet.scalapb:scalapb-json4s_3": {
|
||||
"com.thesamet.scalapb:scalapb-json4s_2.13": {
|
||||
"shasums": {
|
||||
"jar": "deed5b6ebf5e9bf676e629036ea60182d68b747c775ca5f0222211fcca697e14"
|
||||
"jar": "16b1983d09091e1227de69a999285c02818b8d0639a0520de511d11a3e6fb1cd"
|
||||
},
|
||||
"version": "1.0.0-alpha.1"
|
||||
},
|
||||
"com.thesamet.scalapb:scalapb-runtime-grpc_3": {
|
||||
"com.thesamet.scalapb:scalapb-runtime-grpc_2.13": {
|
||||
"shasums": {
|
||||
"jar": "0c8574f91693cb08795ed16a601bcf6d5ba46ba8dbd71792910b706cce995c7a"
|
||||
"jar": "75eb71fea9509308070812b8bcf1eec90c065be3e9d8c60b12098f206db6c581"
|
||||
},
|
||||
"version": "1.0.0-alpha.1"
|
||||
},
|
||||
"com.thesamet.scalapb:scalapb-runtime_3": {
|
||||
"com.thesamet.scalapb:scalapb-runtime_2.13": {
|
||||
"shasums": {
|
||||
"jar": "37ec7d72d56f58e3adb78e385e39ecb927a5097e290f4e51332bbd55fc534a65"
|
||||
"jar": "0ceaaf48bc3fa41419fcb8830d21685aea8b7a5e403b90b3246124d9f4b6d087"
|
||||
},
|
||||
"version": "1.0.0-alpha.1"
|
||||
},
|
||||
"com.thoughtworks.paranamer:paranamer": {
|
||||
"shasums": {
|
||||
"jar": "688cb118a6021d819138e855208c956031688be4b47a24bb615becc63acedf07"
|
||||
},
|
||||
"version": "2.8"
|
||||
},
|
||||
"commons-codec:commons-codec": {
|
||||
"shasums": {
|
||||
"jar": "f9f6cb103f2ddc3c99a9d80ada2ae7bf0685111fd6bffccb72033d1da4e6ff23"
|
||||
@@ -413,12 +383,6 @@
|
||||
},
|
||||
"version": "0.27.0"
|
||||
},
|
||||
"io.sentry:sentry": {
|
||||
"shasums": {
|
||||
"jar": "740a118182fc089d307830f4e508372e01ad94639b00b4e1b1d83762298a5f35"
|
||||
},
|
||||
"version": "7.19.0"
|
||||
},
|
||||
"javax.activation:javax.activation-api": {
|
||||
"shasums": {
|
||||
"jar": "43fdef0b5b6ceb31b0424b208b930c74ab58fac2ceeb7b3f6fd3aeb8b5ca4393"
|
||||
@@ -481,27 +445,15 @@
|
||||
},
|
||||
"org.jetbrains.kotlin:kotlin-stdlib": {
|
||||
"shasums": {
|
||||
"jar": "55e989c512b80907799f854309f3bc7782c5b3d13932442d0379d5c472711504"
|
||||
"jar": "b8ab1da5cdc89cb084d41e1f28f20a42bd431538642a5741c52bbfae3fa3e656"
|
||||
},
|
||||
"version": "1.9.10"
|
||||
"version": "1.4.20"
|
||||
},
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-common": {
|
||||
"shasums": {
|
||||
"jar": "cde3341ba18a2ba262b0b7cf6c55b20c90e8d434e42c9a13e6a3f770db965a88"
|
||||
"jar": "a7112c9b3cefee418286c9c9372f7af992bd1e6e030691d52f60cb36dbec8320"
|
||||
},
|
||||
"version": "1.9.10"
|
||||
},
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk7": {
|
||||
"shasums": {
|
||||
"jar": "ac6361bf9ad1ed382c2103d9712c47cdec166232b4903ed596e8876b0681c9b7"
|
||||
},
|
||||
"version": "1.9.10"
|
||||
},
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk8": {
|
||||
"shasums": {
|
||||
"jar": "a4c74d94d64ce1abe53760fe0389dd941f6fc558d0dab35e47c085a11ec80f28"
|
||||
},
|
||||
"version": "1.9.10"
|
||||
"version": "1.4.20"
|
||||
},
|
||||
"org.jetbrains:annotations": {
|
||||
"shasums": {
|
||||
@@ -509,35 +461,41 @@
|
||||
},
|
||||
"version": "13.0"
|
||||
},
|
||||
"org.json4s:json4s-ast_3": {
|
||||
"org.json4s:json4s-ast_2.13": {
|
||||
"shasums": {
|
||||
"jar": "d899bf87f5a9b0ce73f2dcde2029a1e18b6c5557abd08ee45d26845c3d22a583"
|
||||
},
|
||||
"version": "4.1.0-M8"
|
||||
},
|
||||
"org.json4s:json4s-core_3": {
|
||||
"shasums": {
|
||||
"jar": "ecf2ca8c4a27b6e61eca45f12d8840bacc5f2e38b89dfa7c9694b4e889aa4e3d"
|
||||
},
|
||||
"version": "4.1.0-M8"
|
||||
},
|
||||
"org.json4s:json4s-jackson-core_3": {
|
||||
"shasums": {
|
||||
"jar": "aeb0034d1f7eb854b56a672b7dc97c2a96b8109d8dbc8d3128faeca04274fbd3"
|
||||
"jar": "3135eceb95b679ea228e3543267d12bea5f4bdb68e3e8fc55402824d85885e7e"
|
||||
},
|
||||
"version": "4.0.7"
|
||||
},
|
||||
"org.json4s:json4s-native-core_3": {
|
||||
"org.json4s:json4s-core_2.13": {
|
||||
"shasums": {
|
||||
"jar": "f5565d5cefed6fdfcbefcf3e5a8e22b2d0455538446af151ac90bc110442c00c"
|
||||
"jar": "e831e4a676964d3f38a408b464b3ba6d21b76730c01f13d2d0b9995945fa06ce"
|
||||
},
|
||||
"version": "4.1.0-M8"
|
||||
"version": "4.0.7"
|
||||
},
|
||||
"org.json4s:json4s-native_3": {
|
||||
"org.json4s:json4s-jackson-core_2.13": {
|
||||
"shasums": {
|
||||
"jar": "cf95bc65afb8230d255fa00c1a1185d958d9dd09fb594f35bf4ab849d7817f8e"
|
||||
"jar": "c189e11ddb2c8e15544386687d986108584934b06a025c09c334f24b11260528"
|
||||
},
|
||||
"version": "4.1.0-M8"
|
||||
"version": "4.0.7"
|
||||
},
|
||||
"org.json4s:json4s-native-core_2.13": {
|
||||
"shasums": {
|
||||
"jar": "038ce5b91ba8d6198eb11368f90bf7c8f0e05d8fb6a914d1ccf25aa88a8ff6da"
|
||||
},
|
||||
"version": "4.0.7"
|
||||
},
|
||||
"org.json4s:json4s-native_2.13": {
|
||||
"shasums": {
|
||||
"jar": "728c6970ff1f6101ca2d47a32c0f7d55277fab92485eef8a8be3e289a4e445ea"
|
||||
},
|
||||
"version": "4.0.7"
|
||||
},
|
||||
"org.json4s:json4s-scalap_2.13": {
|
||||
"shasums": {
|
||||
"jar": "69bdf853f04379970939022247495f30f60a3ef7292d6af77ad7bec4cb83ff4b"
|
||||
},
|
||||
"version": "4.0.7"
|
||||
},
|
||||
"org.ow2.asm:asm": {
|
||||
"shasums": {
|
||||
@@ -551,29 +509,29 @@
|
||||
},
|
||||
"version": "1.0.4"
|
||||
},
|
||||
"org.scala-lang.modules:scala-collection-compat_3": {
|
||||
"org.scala-lang.modules:scala-collection-compat_2.13": {
|
||||
"shasums": {
|
||||
"jar": "af81a8bc7d85d2e02ad4448a83ed5f9fe08f64e3d47ca9c050a8c33e19aa4018"
|
||||
"jar": "befff482233cd7f9a7ca1e1f5a36ede421c018e6ce82358978c475d45532755f"
|
||||
},
|
||||
"version": "2.12.0"
|
||||
},
|
||||
"org.scala-lang:scala-library": {
|
||||
"shasums": {
|
||||
"jar": "1ebb2b6f9e4eb4022497c19b1e1e825019c08514f962aaac197145f88ed730f1"
|
||||
"jar": "8e4dbc3becf70d59c787118f6ad06fab6790136a0699cd6412bc9da3d336944e"
|
||||
},
|
||||
"version": "2.13.16"
|
||||
"version": "2.13.15"
|
||||
},
|
||||
"org.scala-lang:scala3-library_3": {
|
||||
"org.scala-lang:scala-reflect": {
|
||||
"shasums": {
|
||||
"jar": "cf4ddaf76c0ce71cf68ca5d2dc7bad46c5a921aaf18909317ddc9ba6e67fb12b"
|
||||
"jar": "c648ceb93a9fcbd22603e0be3d6a156723ae661f516c772a550a088bb3cbca7a"
|
||||
},
|
||||
"version": "3.3.6"
|
||||
"version": "2.13.12"
|
||||
},
|
||||
"org.scalamock:scalamock_3": {
|
||||
"org.scalamock:scalamock_2.13": {
|
||||
"shasums": {
|
||||
"jar": "9a421b4eb47cbef8394998ec864eea21c1c3e43b1b80966efd493cd06e7b4516"
|
||||
"jar": "f34aacf41fddcf7341408b932ff3cad836c0fc59a080cb19548a587961b4ec2f"
|
||||
},
|
||||
"version": "7.4.1"
|
||||
"version": "6.0.0"
|
||||
},
|
||||
"org.slf4j:slf4j-api": {
|
||||
"shasums": {
|
||||
@@ -828,66 +786,48 @@
|
||||
"org.checkerframework:checker-qual",
|
||||
"org.ow2.asm:asm"
|
||||
],
|
||||
"com.nimbusds:nimbus-jose-jwt": [
|
||||
"com.github.stephenc.jcip:jcip-annotations"
|
||||
],
|
||||
"com.squareup.okhttp3:okhttp": [
|
||||
"com.squareup.okio:okio",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk8"
|
||||
],
|
||||
"com.squareup.okhttp3:okhttp-sse": [
|
||||
"com.squareup.okhttp3:okhttp",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk8"
|
||||
],
|
||||
"com.squareup.okhttp:okhttp": [
|
||||
"com.squareup.okio:okio"
|
||||
],
|
||||
"com.squareup.okio:okio": [
|
||||
"com.squareup.okio:okio-jvm"
|
||||
"org.jetbrains.kotlin:kotlin-stdlib",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-common"
|
||||
],
|
||||
"com.squareup.okio:okio-jvm": [
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-common",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk8"
|
||||
],
|
||||
"com.thesamet.scalapb:compilerplugin_3": [
|
||||
"com.thesamet.scalapb:compilerplugin_2.13": [
|
||||
"com.google.protobuf:protobuf-java",
|
||||
"com.thesamet.scalapb:protoc-gen_2.13",
|
||||
"org.scala-lang.modules:scala-collection-compat_3",
|
||||
"org.scala-lang:scala3-library_3"
|
||||
"org.scala-lang.modules:scala-collection-compat_2.13",
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"com.thesamet.scalapb:lenses_3": [
|
||||
"org.scala-lang.modules:scala-collection-compat_3",
|
||||
"org.scala-lang:scala3-library_3"
|
||||
"com.thesamet.scalapb:lenses_2.13": [
|
||||
"org.scala-lang.modules:scala-collection-compat_2.13",
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"com.thesamet.scalapb:protoc-bridge_2.13": [
|
||||
"dev.dirs:directories",
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"com.thesamet.scalapb:protoc-bridge_3": [
|
||||
"dev.dirs:directories",
|
||||
"org.scala-lang:scala3-library_3"
|
||||
],
|
||||
"com.thesamet.scalapb:protoc-gen_2.13": [
|
||||
"com.thesamet.scalapb:protoc-bridge_2.13",
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"com.thesamet.scalapb:scalapb-json4s_3": [
|
||||
"com.thesamet.scalapb:scalapb-runtime_3",
|
||||
"org.json4s:json4s-jackson-core_3",
|
||||
"org.scala-lang:scala3-library_3"
|
||||
"com.thesamet.scalapb:scalapb-json4s_2.13": [
|
||||
"com.thesamet.scalapb:scalapb-runtime_2.13",
|
||||
"org.json4s:json4s-jackson-core_2.13",
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"com.thesamet.scalapb:scalapb-runtime-grpc_3": [
|
||||
"com.thesamet.scalapb:scalapb-runtime_3",
|
||||
"com.thesamet.scalapb:scalapb-runtime-grpc_2.13": [
|
||||
"com.thesamet.scalapb:scalapb-runtime_2.13",
|
||||
"io.grpc:grpc-protobuf",
|
||||
"io.grpc:grpc-stub",
|
||||
"org.scala-lang.modules:scala-collection-compat_3",
|
||||
"org.scala-lang:scala3-library_3"
|
||||
"org.scala-lang.modules:scala-collection-compat_2.13",
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"com.thesamet.scalapb:scalapb-runtime_3": [
|
||||
"com.thesamet.scalapb:scalapb-runtime_2.13": [
|
||||
"com.google.protobuf:protobuf-java",
|
||||
"com.thesamet.scalapb:lenses_3",
|
||||
"org.scala-lang.modules:scala-collection-compat_3",
|
||||
"org.scala-lang:scala3-library_3"
|
||||
"com.thesamet.scalapb:lenses_2.13",
|
||||
"org.scala-lang.modules:scala-collection-compat_2.13",
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"io.grpc:grpc-api": [
|
||||
"com.google.code.findbugs:jsr305",
|
||||
@@ -1055,42 +995,41 @@
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-common",
|
||||
"org.jetbrains:annotations"
|
||||
],
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk7": [
|
||||
"org.jetbrains.kotlin:kotlin-stdlib"
|
||||
],
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk8": [
|
||||
"org.jetbrains.kotlin:kotlin-stdlib",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk7"
|
||||
],
|
||||
"org.json4s:json4s-ast_3": [
|
||||
"org.scala-lang:scala3-library_3"
|
||||
],
|
||||
"org.json4s:json4s-core_3": [
|
||||
"org.json4s:json4s-ast_3",
|
||||
"org.scala-lang:scala3-library_3"
|
||||
],
|
||||
"org.json4s:json4s-jackson-core_3": [
|
||||
"com.fasterxml.jackson.core:jackson-databind",
|
||||
"org.json4s:json4s-ast_3",
|
||||
"org.scala-lang:scala3-library_3"
|
||||
],
|
||||
"org.json4s:json4s-native-core_3": [
|
||||
"org.json4s:json4s-ast_3",
|
||||
"org.scala-lang:scala3-library_3"
|
||||
],
|
||||
"org.json4s:json4s-native_3": [
|
||||
"org.json4s:json4s-core_3",
|
||||
"org.json4s:json4s-native-core_3",
|
||||
"org.scala-lang:scala3-library_3"
|
||||
],
|
||||
"org.scala-lang.modules:scala-collection-compat_3": [
|
||||
"org.scala-lang:scala3-library_3"
|
||||
],
|
||||
"org.scala-lang:scala3-library_3": [
|
||||
"org.json4s:json4s-ast_2.13": [
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"org.scalamock:scalamock_3": [
|
||||
"org.scala-lang:scala3-library_3"
|
||||
"org.json4s:json4s-core_2.13": [
|
||||
"com.thoughtworks.paranamer:paranamer",
|
||||
"org.json4s:json4s-ast_2.13",
|
||||
"org.json4s:json4s-scalap_2.13",
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"org.json4s:json4s-jackson-core_2.13": [
|
||||
"com.fasterxml.jackson.core:jackson-databind",
|
||||
"org.json4s:json4s-ast_2.13",
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"org.json4s:json4s-native-core_2.13": [
|
||||
"org.json4s:json4s-ast_2.13",
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"org.json4s:json4s-native_2.13": [
|
||||
"org.json4s:json4s-core_2.13",
|
||||
"org.json4s:json4s-native-core_2.13",
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"org.json4s:json4s-scalap_2.13": [
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"org.scala-lang.modules:scala-collection-compat_2.13": [
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"org.scala-lang:scala-reflect": [
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"org.scalamock:scalamock_2.13": [
|
||||
"org.scala-lang:scala-library",
|
||||
"org.scala-lang:scala-reflect"
|
||||
],
|
||||
"org.slf4j:slf4j-simple": [
|
||||
"org.slf4j:slf4j-api"
|
||||
@@ -1376,9 +1315,6 @@
|
||||
"com.fasterxml.jackson.databind.type",
|
||||
"com.fasterxml.jackson.databind.util"
|
||||
],
|
||||
"com.github.stephenc.jcip:jcip-annotations": [
|
||||
"net.jcip.annotations"
|
||||
],
|
||||
"com.google.android:annotations": [
|
||||
"android.annotation"
|
||||
],
|
||||
@@ -1524,61 +1460,6 @@
|
||||
"com.google.truth:truth": [
|
||||
"com.google.common.truth"
|
||||
],
|
||||
"com.nimbusds:nimbus-jose-jwt": [
|
||||
"com.nimbusds.jose",
|
||||
"com.nimbusds.jose.crypto",
|
||||
"com.nimbusds.jose.crypto.bc",
|
||||
"com.nimbusds.jose.crypto.factories",
|
||||
"com.nimbusds.jose.crypto.impl",
|
||||
"com.nimbusds.jose.crypto.opts",
|
||||
"com.nimbusds.jose.crypto.utils",
|
||||
"com.nimbusds.jose.jca",
|
||||
"com.nimbusds.jose.jwk",
|
||||
"com.nimbusds.jose.jwk.gen",
|
||||
"com.nimbusds.jose.jwk.source",
|
||||
"com.nimbusds.jose.mint",
|
||||
"com.nimbusds.jose.proc",
|
||||
"com.nimbusds.jose.produce",
|
||||
"com.nimbusds.jose.shaded.gson",
|
||||
"com.nimbusds.jose.shaded.gson.annotations",
|
||||
"com.nimbusds.jose.shaded.gson.internal",
|
||||
"com.nimbusds.jose.shaded.gson.internal.bind",
|
||||
"com.nimbusds.jose.shaded.gson.internal.bind.util",
|
||||
"com.nimbusds.jose.shaded.gson.internal.reflect",
|
||||
"com.nimbusds.jose.shaded.gson.internal.sql",
|
||||
"com.nimbusds.jose.shaded.gson.reflect",
|
||||
"com.nimbusds.jose.shaded.gson.stream",
|
||||
"com.nimbusds.jose.util",
|
||||
"com.nimbusds.jose.util.cache",
|
||||
"com.nimbusds.jose.util.events",
|
||||
"com.nimbusds.jose.util.health",
|
||||
"com.nimbusds.jwt",
|
||||
"com.nimbusds.jwt.proc",
|
||||
"com.nimbusds.jwt.util"
|
||||
],
|
||||
"com.squareup.okhttp3:okhttp": [
|
||||
"okhttp3",
|
||||
"okhttp3.internal",
|
||||
"okhttp3.internal.authenticator",
|
||||
"okhttp3.internal.cache",
|
||||
"okhttp3.internal.cache2",
|
||||
"okhttp3.internal.concurrent",
|
||||
"okhttp3.internal.connection",
|
||||
"okhttp3.internal.http",
|
||||
"okhttp3.internal.http1",
|
||||
"okhttp3.internal.http2",
|
||||
"okhttp3.internal.io",
|
||||
"okhttp3.internal.platform",
|
||||
"okhttp3.internal.platform.android",
|
||||
"okhttp3.internal.proxy",
|
||||
"okhttp3.internal.publicsuffix",
|
||||
"okhttp3.internal.tls",
|
||||
"okhttp3.internal.ws"
|
||||
],
|
||||
"com.squareup.okhttp3:okhttp-sse": [
|
||||
"okhttp3.internal.sse",
|
||||
"okhttp3.sse"
|
||||
],
|
||||
"com.squareup.okhttp:okhttp": [
|
||||
"com.squareup.okhttp",
|
||||
"com.squareup.okhttp.internal",
|
||||
@@ -1587,18 +1468,18 @@
|
||||
"com.squareup.okhttp.internal.io",
|
||||
"com.squareup.okhttp.internal.tls"
|
||||
],
|
||||
"com.squareup.okio:okio-jvm": [
|
||||
"com.squareup.okio:okio": [
|
||||
"okio",
|
||||
"okio.internal"
|
||||
],
|
||||
"com.thesamet.scalapb:compilerplugin_3": [
|
||||
"com.thesamet.scalapb:compilerplugin_2.13": [
|
||||
"scalapb",
|
||||
"scalapb.compiler",
|
||||
"scalapb.internal",
|
||||
"scalapb.options",
|
||||
"scalapb.options.compiler"
|
||||
],
|
||||
"com.thesamet.scalapb:lenses_3": [
|
||||
"com.thesamet.scalapb:lenses_2.13": [
|
||||
"scalapb.lenses"
|
||||
],
|
||||
"com.thesamet.scalapb:protoc-bridge_2.13": [
|
||||
@@ -1606,21 +1487,16 @@
|
||||
"protocbridge.codegen",
|
||||
"protocbridge.frontend"
|
||||
],
|
||||
"com.thesamet.scalapb:protoc-bridge_3": [
|
||||
"protocbridge",
|
||||
"protocbridge.codegen",
|
||||
"protocbridge.frontend"
|
||||
],
|
||||
"com.thesamet.scalapb:protoc-gen_2.13": [
|
||||
"protocgen"
|
||||
],
|
||||
"com.thesamet.scalapb:scalapb-json4s_3": [
|
||||
"com.thesamet.scalapb:scalapb-json4s_2.13": [
|
||||
"scalapb.json4s"
|
||||
],
|
||||
"com.thesamet.scalapb:scalapb-runtime-grpc_3": [
|
||||
"com.thesamet.scalapb:scalapb-runtime-grpc_2.13": [
|
||||
"scalapb.grpc"
|
||||
],
|
||||
"com.thesamet.scalapb:scalapb-runtime_3": [
|
||||
"com.thesamet.scalapb:scalapb-runtime_2.13": [
|
||||
"com.google.protobuf.any",
|
||||
"com.google.protobuf.api",
|
||||
"com.google.protobuf.compiler.plugin",
|
||||
@@ -1639,6 +1515,9 @@
|
||||
"scalapb.options",
|
||||
"scalapb.textformat"
|
||||
],
|
||||
"com.thoughtworks.paranamer:paranamer": [
|
||||
"com.thoughtworks.paranamer"
|
||||
],
|
||||
"commons-codec:commons-codec": [
|
||||
"org.apache.commons.codec",
|
||||
"org.apache.commons.codec.binary",
|
||||
@@ -1791,30 +1670,6 @@
|
||||
"io.perfmark:perfmark-api": [
|
||||
"io.perfmark"
|
||||
],
|
||||
"io.sentry:sentry": [
|
||||
"io.sentry",
|
||||
"io.sentry.backpressure",
|
||||
"io.sentry.cache",
|
||||
"io.sentry.clientreport",
|
||||
"io.sentry.config",
|
||||
"io.sentry.exception",
|
||||
"io.sentry.hints",
|
||||
"io.sentry.instrumentation.file",
|
||||
"io.sentry.internal.debugmeta",
|
||||
"io.sentry.internal.gestures",
|
||||
"io.sentry.internal.modules",
|
||||
"io.sentry.internal.viewhierarchy",
|
||||
"io.sentry.metrics",
|
||||
"io.sentry.profilemeasurements",
|
||||
"io.sentry.protocol",
|
||||
"io.sentry.rrweb",
|
||||
"io.sentry.transport",
|
||||
"io.sentry.util",
|
||||
"io.sentry.util.thread",
|
||||
"io.sentry.vendor",
|
||||
"io.sentry.vendor.gson.internal.bind.util",
|
||||
"io.sentry.vendor.gson.stream"
|
||||
],
|
||||
"javax.activation:javax.activation-api": [
|
||||
"javax.activation"
|
||||
],
|
||||
@@ -1966,7 +1821,6 @@
|
||||
"kotlin.annotation",
|
||||
"kotlin.collections",
|
||||
"kotlin.collections.builders",
|
||||
"kotlin.collections.jdk8",
|
||||
"kotlin.collections.unsigned",
|
||||
"kotlin.comparisons",
|
||||
"kotlin.concurrent",
|
||||
@@ -1975,59 +1829,51 @@
|
||||
"kotlin.coroutines.cancellation",
|
||||
"kotlin.coroutines.intrinsics",
|
||||
"kotlin.coroutines.jvm.internal",
|
||||
"kotlin.enums",
|
||||
"kotlin.experimental",
|
||||
"kotlin.internal",
|
||||
"kotlin.internal.jdk7",
|
||||
"kotlin.internal.jdk8",
|
||||
"kotlin.io",
|
||||
"kotlin.io.encoding",
|
||||
"kotlin.io.path",
|
||||
"kotlin.jdk7",
|
||||
"kotlin.js",
|
||||
"kotlin.jvm",
|
||||
"kotlin.jvm.functions",
|
||||
"kotlin.jvm.internal",
|
||||
"kotlin.jvm.internal.markers",
|
||||
"kotlin.jvm.internal.unsafe",
|
||||
"kotlin.jvm.jdk8",
|
||||
"kotlin.jvm.optionals",
|
||||
"kotlin.math",
|
||||
"kotlin.properties",
|
||||
"kotlin.random",
|
||||
"kotlin.random.jdk8",
|
||||
"kotlin.ranges",
|
||||
"kotlin.reflect",
|
||||
"kotlin.sequences",
|
||||
"kotlin.streams.jdk8",
|
||||
"kotlin.system",
|
||||
"kotlin.text",
|
||||
"kotlin.text.jdk8",
|
||||
"kotlin.time",
|
||||
"kotlin.time.jdk8"
|
||||
"kotlin.time"
|
||||
],
|
||||
"org.jetbrains:annotations": [
|
||||
"org.intellij.lang.annotations",
|
||||
"org.jetbrains.annotations"
|
||||
],
|
||||
"org.json4s:json4s-ast_3": [
|
||||
"org.json4s:json4s-ast_2.13": [
|
||||
"org.json4s",
|
||||
"org.json4s.prefs"
|
||||
],
|
||||
"org.json4s:json4s-core_3": [
|
||||
"org.json4s:json4s-core_2.13": [
|
||||
"org.json4s",
|
||||
"org.json4s.prefs",
|
||||
"org.json4s.reflect"
|
||||
],
|
||||
"org.json4s:json4s-jackson-core_3": [
|
||||
"org.json4s:json4s-jackson-core_2.13": [
|
||||
"org.json4s.jackson"
|
||||
],
|
||||
"org.json4s:json4s-native-core_3": [
|
||||
"org.json4s:json4s-native-core_2.13": [
|
||||
"org.json4s.native"
|
||||
],
|
||||
"org.json4s:json4s-native_3": [
|
||||
"org.json4s:json4s-native_2.13": [
|
||||
"org.json4s.native"
|
||||
],
|
||||
"org.json4s:json4s-scalap_2.13": [
|
||||
"org.json4s.scalap",
|
||||
"org.json4s.scalap.scalasig"
|
||||
],
|
||||
"org.ow2.asm:asm": [
|
||||
"org.objectweb.asm",
|
||||
"org.objectweb.asm.signature"
|
||||
@@ -2035,7 +1881,7 @@
|
||||
"org.reactivestreams:reactive-streams": [
|
||||
"org.reactivestreams"
|
||||
],
|
||||
"org.scala-lang.modules:scala-collection-compat_3": [
|
||||
"org.scala-lang.modules:scala-collection-compat_2.13": [
|
||||
"scala.collection.compat",
|
||||
"scala.collection.compat.immutable",
|
||||
"scala.util.control.compat",
|
||||
@@ -2074,26 +1920,22 @@
|
||||
"scala.util.hashing",
|
||||
"scala.util.matching"
|
||||
],
|
||||
"org.scala-lang:scala3-library_3": [
|
||||
"scala",
|
||||
"scala.annotation",
|
||||
"scala.annotation.internal",
|
||||
"scala.annotation.unchecked",
|
||||
"scala.compiletime",
|
||||
"scala.compiletime.ops",
|
||||
"scala.compiletime.testing",
|
||||
"scala.deriving",
|
||||
"scala.quoted",
|
||||
"scala.quoted.runtime",
|
||||
"scala.reflect",
|
||||
"scala.runtime",
|
||||
"scala.runtime.coverage",
|
||||
"scala.runtime.function",
|
||||
"scala.runtime.stdLibPatches",
|
||||
"scala.util",
|
||||
"scala.util.control"
|
||||
"org.scala-lang:scala-reflect": [
|
||||
"scala.reflect.api",
|
||||
"scala.reflect.internal",
|
||||
"scala.reflect.internal.annotations",
|
||||
"scala.reflect.internal.pickling",
|
||||
"scala.reflect.internal.settings",
|
||||
"scala.reflect.internal.tpe",
|
||||
"scala.reflect.internal.transform",
|
||||
"scala.reflect.internal.util",
|
||||
"scala.reflect.io",
|
||||
"scala.reflect.macros",
|
||||
"scala.reflect.macros.blackbox",
|
||||
"scala.reflect.macros.whitebox",
|
||||
"scala.reflect.runtime"
|
||||
],
|
||||
"org.scalamock:scalamock_3": [
|
||||
"org.scalamock:scalamock_2.13": [
|
||||
"org.scalamock",
|
||||
"org.scalamock.clazz",
|
||||
"org.scalamock.context",
|
||||
@@ -2104,8 +1946,6 @@
|
||||
"org.scalamock.scalatest",
|
||||
"org.scalamock.scalatest.proxy",
|
||||
"org.scalamock.specs2",
|
||||
"org.scalamock.stubs",
|
||||
"org.scalamock.stubs.internal",
|
||||
"org.scalamock.util"
|
||||
],
|
||||
"org.slf4j:slf4j-api": [
|
||||
@@ -2417,7 +2257,6 @@
|
||||
"com.fasterxml.jackson.core:jackson-annotations",
|
||||
"com.fasterxml.jackson.core:jackson-core",
|
||||
"com.fasterxml.jackson.core:jackson-databind",
|
||||
"com.github.stephenc.jcip:jcip-annotations",
|
||||
"com.google.android:annotations",
|
||||
"com.google.api.grpc:proto-google-common-protos",
|
||||
"com.google.auth:google-auth-library-credentials",
|
||||
@@ -2436,20 +2275,16 @@
|
||||
"com.google.protobuf:protobuf-java",
|
||||
"com.google.re2j:re2j",
|
||||
"com.google.truth:truth",
|
||||
"com.nimbusds:nimbus-jose-jwt",
|
||||
"com.squareup.okhttp3:okhttp",
|
||||
"com.squareup.okhttp3:okhttp-sse",
|
||||
"com.squareup.okhttp:okhttp",
|
||||
"com.squareup.okio:okio",
|
||||
"com.squareup.okio:okio-jvm",
|
||||
"com.thesamet.scalapb:compilerplugin_3",
|
||||
"com.thesamet.scalapb:lenses_3",
|
||||
"com.thesamet.scalapb:compilerplugin_2.13",
|
||||
"com.thesamet.scalapb:lenses_2.13",
|
||||
"com.thesamet.scalapb:protoc-bridge_2.13",
|
||||
"com.thesamet.scalapb:protoc-bridge_3",
|
||||
"com.thesamet.scalapb:protoc-gen_2.13",
|
||||
"com.thesamet.scalapb:scalapb-json4s_3",
|
||||
"com.thesamet.scalapb:scalapb-runtime-grpc_3",
|
||||
"com.thesamet.scalapb:scalapb-runtime_3",
|
||||
"com.thesamet.scalapb:scalapb-json4s_2.13",
|
||||
"com.thesamet.scalapb:scalapb-runtime-grpc_2.13",
|
||||
"com.thesamet.scalapb:scalapb-runtime_2.13",
|
||||
"com.thoughtworks.paranamer:paranamer",
|
||||
"commons-codec:commons-codec",
|
||||
"commons-logging:commons-logging",
|
||||
"dev.dirs:directories",
|
||||
@@ -2482,7 +2317,6 @@
|
||||
"io.opencensus:opencensus-contrib-grpc-metrics",
|
||||
"io.opencensus:opencensus-contrib-http-util",
|
||||
"io.perfmark:perfmark-api",
|
||||
"io.sentry:sentry",
|
||||
"javax.activation:javax.activation-api",
|
||||
"javax.xml.bind:jaxb-api",
|
||||
"joda-time:joda-time",
|
||||
@@ -2495,20 +2329,19 @@
|
||||
"org.hamcrest:hamcrest-core",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-common",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk7",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk8",
|
||||
"org.jetbrains:annotations",
|
||||
"org.json4s:json4s-ast_3",
|
||||
"org.json4s:json4s-core_3",
|
||||
"org.json4s:json4s-jackson-core_3",
|
||||
"org.json4s:json4s-native-core_3",
|
||||
"org.json4s:json4s-native_3",
|
||||
"org.json4s:json4s-ast_2.13",
|
||||
"org.json4s:json4s-core_2.13",
|
||||
"org.json4s:json4s-jackson-core_2.13",
|
||||
"org.json4s:json4s-native-core_2.13",
|
||||
"org.json4s:json4s-native_2.13",
|
||||
"org.json4s:json4s-scalap_2.13",
|
||||
"org.ow2.asm:asm",
|
||||
"org.reactivestreams:reactive-streams",
|
||||
"org.scala-lang.modules:scala-collection-compat_3",
|
||||
"org.scala-lang.modules:scala-collection-compat_2.13",
|
||||
"org.scala-lang:scala-library",
|
||||
"org.scala-lang:scala3-library_3",
|
||||
"org.scalamock:scalamock_3",
|
||||
"org.scala-lang:scala-reflect",
|
||||
"org.scalamock:scalamock_2.13",
|
||||
"org.slf4j:slf4j-api",
|
||||
"org.slf4j:slf4j-simple",
|
||||
"software.amazon.awssdk:annotations",
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
# Allow large request bodies for game uploads (default is 1MB)
|
||||
client_max_body_size 50M;
|
||||
|
||||
# Logging
|
||||
log_format grpc_json escape=json '{'
|
||||
'"time":"$time_iso8601",'
|
||||
'"client":"$remote_addr",'
|
||||
'"uri":"$uri",'
|
||||
'"status":$status,'
|
||||
'"grpc_status":"$sent_http_grpc_status",'
|
||||
'"request_time":$request_time,'
|
||||
'"upstream_time":"$upstream_response_time"'
|
||||
'}';
|
||||
|
||||
access_log /var/log/nginx/access.log grpc_json;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
|
||||
# Rate limiting zone
|
||||
limit_req_zone $binary_remote_addr zone=grpc_limit:10m rate=100r/s;
|
||||
|
||||
# Docker DNS resolver - re-resolve hostnames every 10s
|
||||
# This prevents stale IP caching when containers restart
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
# Eagle backend - blue-green deployment with variable-based routing
|
||||
# Uses a variable so nginx only resolves the configured backend (not all backends).
|
||||
# This allows nginx to start/reload even when the inactive backend is stopped.
|
||||
# The deploy script updates this map, then recreates nginx.
|
||||
map $host $eagle_backend {
|
||||
default "eagle-blue:40032";
|
||||
}
|
||||
|
||||
# HTTP server for Let's Encrypt challenge and redirect
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name prod.eagle0.net;
|
||||
|
||||
# Let's Encrypt challenge
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
# Redirect all other HTTP to HTTPS
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS server for gRPC
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
server_name prod.eagle0.net;
|
||||
|
||||
# SSL certificates (managed by certbot)
|
||||
ssl_certificate /etc/letsencrypt/live/prod.eagle0.net/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/prod.eagle0.net/privkey.pem;
|
||||
|
||||
# SSL configuration
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# gRPC proxy for Eagle service
|
||||
location /net.eagle0.eagle.api.Eagle {
|
||||
# Rate limiting
|
||||
limit_req zone=grpc_limit burst=50 nodelay;
|
||||
|
||||
# gRPC proxy - uses variable for blue-green deployment
|
||||
grpc_pass grpc://$eagle_backend;
|
||||
|
||||
# Timeouts for long-running streams
|
||||
grpc_read_timeout 1200s;
|
||||
grpc_send_timeout 1200s;
|
||||
grpc_socket_keepalive on;
|
||||
|
||||
# Error handling
|
||||
error_page 502 = /error502grpc;
|
||||
}
|
||||
|
||||
# gRPC proxy for Auth service (routes to Go auth service, not Eagle)
|
||||
location /net.eagle0.eagle.api.auth.Auth {
|
||||
# Rate limiting
|
||||
limit_req zone=grpc_limit burst=50 nodelay;
|
||||
|
||||
# Route to auth service directly (not through Eagle)
|
||||
set $auth_backend "auth:40033";
|
||||
grpc_pass grpc://$auth_backend;
|
||||
|
||||
# Timeouts
|
||||
grpc_read_timeout 30s;
|
||||
grpc_send_timeout 30s;
|
||||
|
||||
# Error handling
|
||||
error_page 502 = /error502grpc;
|
||||
}
|
||||
|
||||
# OAuth callback endpoint (proxied to Go auth service)
|
||||
location /oauth/callback {
|
||||
proxy_pass http://auth:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Invitation landing page (proxied to Go auth service)
|
||||
location /invite/ {
|
||||
proxy_pass http://auth:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "OK\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# gRPC error handling
|
||||
location = /error502grpc {
|
||||
internal;
|
||||
default_type application/grpc;
|
||||
add_header grpc-status 14;
|
||||
add_header grpc-message "unavailable";
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS server for Go Auth service (port 40033)
|
||||
# Clients connect here directly for OAuth RPCs in Phase 2
|
||||
server {
|
||||
listen 40033 ssl;
|
||||
listen [::]:40033 ssl;
|
||||
http2 on;
|
||||
server_name prod.eagle0.net;
|
||||
|
||||
# SSL certificates (same as main server)
|
||||
ssl_certificate /etc/letsencrypt/live/prod.eagle0.net/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/prod.eagle0.net/privkey.pem;
|
||||
|
||||
# SSL configuration
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# gRPC proxy for Auth service
|
||||
# Uses variable-based resolution so nginx can start even if auth isn't ready yet
|
||||
# DNS is cached by the resolver directive (valid=10s)
|
||||
location /net.eagle0.eagle.api.auth.Auth {
|
||||
# Rate limiting
|
||||
limit_req zone=grpc_limit burst=50 nodelay;
|
||||
|
||||
# Dynamic upstream resolution (doesn't block nginx startup)
|
||||
set $auth_backend "auth:40033";
|
||||
grpc_pass grpc://$auth_backend;
|
||||
|
||||
# Timeouts
|
||||
grpc_read_timeout 30s;
|
||||
grpc_send_timeout 30s;
|
||||
|
||||
# Error handling
|
||||
error_page 502 = /error502grpc;
|
||||
}
|
||||
|
||||
# gRPC proxy for Admin service
|
||||
location /net.eagle0.eagle.api.admin.Admin {
|
||||
# Rate limiting
|
||||
limit_req zone=grpc_limit burst=50 nodelay;
|
||||
|
||||
# Dynamic upstream resolution (doesn't block nginx startup)
|
||||
set $auth_backend "auth:40033";
|
||||
grpc_pass grpc://$auth_backend;
|
||||
|
||||
# Timeouts
|
||||
grpc_read_timeout 30s;
|
||||
grpc_send_timeout 30s;
|
||||
|
||||
# Error handling
|
||||
error_page 502 = /error502grpc;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "OK\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# gRPC error handling
|
||||
location = /error502grpc {
|
||||
internal;
|
||||
default_type application/grpc;
|
||||
add_header grpc-status 14;
|
||||
add_header grpc-message "unavailable";
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTP server for Admin Console (Let's Encrypt + redirect)
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name admin.prod.eagle0.net admin.eagle0.net;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS server for Admin Console
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name admin.prod.eagle0.net admin.eagle0.net;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/admin.eagle0.net/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/admin.eagle0.net/privkey.pem;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
location / {
|
||||
proxy_pass http://admin:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,7 @@
|
||||
set -euxo pipefail
|
||||
|
||||
/bin/echo "building darwin bundle"
|
||||
bazel build --config=mactools @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle
|
||||
ZIP_LOCATION=$(bazel cquery --config=mactools --output=files @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle 2>/dev/null)
|
||||
/usr/bin/unzip -o $ZIP_LOCATION -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
|
||||
bazel build --noincompatible_enable_cc_toolchain_resolution @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle
|
||||
/usr/bin/unzip -o bazel-bin/external/net_eagle0_unity_godice/darwin/framework/DarwinGodiceBundle.zip -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
|
||||
|
||||
/usr/bin/plutil -convert xml1 src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/DarwinGodiceBundle.bundle/Contents/Info.plist
|
||||
|
||||
/bin/echo "building sparkle plugin"
|
||||
./scripts/build_sparkle_plugin.sh
|
||||
|
||||
@@ -5,9 +5,8 @@ set -euxo pipefail
|
||||
/bin/echo "build plugins"
|
||||
|
||||
/bin/echo "building darwin bundle"
|
||||
bazel build --config=mactools @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle
|
||||
ZIP_LOCATION=$(bazel cquery --config=mactools --output=files @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle 2>/dev/null)
|
||||
/usr/bin/unzip -o $ZIP_LOCATION -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
|
||||
bazel build --noincompatible_enable_cc_toolchain_resolution @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle
|
||||
/usr/bin/unzip -o bazel-bin/external/net_eagle0_unity_godice/darwin/framework/DarwinGodiceBundle.zip -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
|
||||
|
||||
/usr/bin/plutil -convert xml1 src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/DarwinGodiceBundle.bundle/Contents/Info.plist
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build the SparklePlugin native library for Unity using Bazel
|
||||
#
|
||||
# Usage: build_sparkle_plugin.sh [output_dir]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
OUTPUT_DIR="${1:-$PROJECT_ROOT/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/macOS}"
|
||||
|
||||
echo "=== Building SparklePlugin with Bazel ==="
|
||||
|
||||
bazel build --config=mactools //src/main/objc/net/eagle0/sparkle:SparklePlugin
|
||||
|
||||
# Get the zip path from bazel
|
||||
ZIP_PATH=$(bazel cquery --config=mactools --output=files //src/main/objc/net/eagle0/sparkle:SparklePlugin 2>/dev/null)
|
||||
|
||||
echo "=== Extracting SparklePlugin.bundle ==="
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
rm -rf "$OUTPUT_DIR/SparklePlugin.bundle"
|
||||
unzip -o "$ZIP_PATH" -d "$OUTPUT_DIR/"
|
||||
|
||||
echo "=== SparklePlugin built successfully ==="
|
||||
ls -la "$OUTPUT_DIR/SparklePlugin.bundle/"
|
||||
@@ -1,88 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Code sign a macOS .app bundle for distribution
|
||||
# Usage: codesign_mac_app.sh <app_path> [entitlements_path]
|
||||
#
|
||||
# Environment variables:
|
||||
# SIGNING_IDENTITY - The signing identity (default: "Developer ID Application")
|
||||
# KEYCHAIN_PASSWORD - Password to unlock the build keychain (optional)
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
APP_PATH="$1"
|
||||
ENTITLEMENTS_PATH="${2:-}"
|
||||
SIGNING_IDENTITY="${SIGNING_IDENTITY:-Developer ID Application}"
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
echo "ERROR: App not found at $APP_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Unlock keychain if password provided
|
||||
if [ -n "${KEYCHAIN_PASSWORD:-}" ]; then
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain || true
|
||||
fi
|
||||
|
||||
echo "=== Signing nested components first ==="
|
||||
|
||||
# Sign all dylibs
|
||||
find "$APP_PATH" -name "*.dylib" -print0 | while IFS= read -r -d '' item; do
|
||||
echo "Signing dylib: $item"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
done
|
||||
|
||||
# Sign all bundles (plugins)
|
||||
find "$APP_PATH" -name "*.bundle" -print0 | while IFS= read -r -d '' item; do
|
||||
echo "Signing bundle: $item"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
done
|
||||
|
||||
# Sign XPC services (inside Sparkle framework)
|
||||
find "$APP_PATH" -name "*.xpc" -print0 | while IFS= read -r -d '' item; do
|
||||
echo "Signing XPC service: $item"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
done
|
||||
|
||||
# Sign nested apps (like Sparkle's Updater.app)
|
||||
find "$APP_PATH" -path "*/Frameworks/*.app" -print0 | while IFS= read -r -d '' item; do
|
||||
echo "Signing nested app: $item"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
done
|
||||
|
||||
# Sign standalone executables inside frameworks (like Autoupdate)
|
||||
find "$APP_PATH" -path "*/Frameworks/*/Versions/*/Autoupdate" -type f -print0 | while IFS= read -r -d '' item; do
|
||||
echo "Signing executable: $item"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
done
|
||||
|
||||
# Sign all frameworks (after their contents are signed)
|
||||
find "$APP_PATH" -name "*.framework" -print0 | while IFS= read -r -d '' item; do
|
||||
echo "Signing framework: $item"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
done
|
||||
|
||||
echo "=== Signing main app bundle ==="
|
||||
|
||||
if [ -n "$ENTITLEMENTS_PATH" ] && [ -f "$ENTITLEMENTS_PATH" ]; then
|
||||
echo "Using entitlements: $ENTITLEMENTS_PATH"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--entitlements "$ENTITLEMENTS_PATH" \
|
||||
--sign "$SIGNING_IDENTITY" "$APP_PATH"
|
||||
else
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$APP_PATH"
|
||||
fi
|
||||
|
||||
echo "=== Verifying signature ==="
|
||||
codesign --verify --verbose=4 "$APP_PATH"
|
||||
|
||||
echo "=== Checking Gatekeeper assessment ==="
|
||||
spctl --assess --type exec -v "$APP_PATH" || echo "Note: Gatekeeper may reject until notarized"
|
||||
|
||||
echo "Code signing complete: $APP_PATH"
|
||||
@@ -1,383 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Blue-Green Deployment Script for Eagle Server
|
||||
#
|
||||
# This script performs a zero-downtime deployment with state consistency:
|
||||
# 1. Create .deployment_in_progress marker (signals deployment started)
|
||||
# 2. Start the staging instance (green) with new image
|
||||
# 3. Run warmup/smoke tests against staging (warms JIT)
|
||||
# 4. Switch nginx to staging (zero downtime - users immediately route to staging)
|
||||
# 5. Stop the active instance (blue) - blocks until flush completes
|
||||
# 6. Create .flush_complete marker (signals disk state is fresh)
|
||||
#
|
||||
# The flush marker coordination ensures green never serves stale game data:
|
||||
# - When users reconnect to green and trigger lazy-load, the code checks for markers
|
||||
# - If .deployment_in_progress exists, lazy-load WAITS for .flush_complete
|
||||
# - Once blue's flush completes and marker is created, lazy-load proceeds with fresh data
|
||||
#
|
||||
# Key insight: nginx switches to green BEFORE blue stops, achieving zero downtime.
|
||||
# Users who trigger lazy-load during blue's shutdown will wait for the flush marker.
|
||||
#
|
||||
# Usage: ./deploy-blue-green.sh [NEW_IMAGE_TAG]
|
||||
#
|
||||
# Example:
|
||||
# ./deploy-blue-green.sh latest
|
||||
# ./deploy-blue-green.sh sha-abc123
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP_DIR="${APP_DIR:-/opt/eagle0}"
|
||||
NGINX_CONF="${APP_DIR}/nginx/nginx.conf"
|
||||
COMPOSE_FILE="${APP_DIR}/docker-compose.prod.yml"
|
||||
WARMUP_SCRIPT="${SCRIPT_DIR}/warmup-eagle.sh"
|
||||
SAVES_DIR="${APP_DIR}/saves"
|
||||
FLUSH_MARKER="${SAVES_DIR}/.flush_complete"
|
||||
DEPLOYMENT_IN_PROGRESS="${SAVES_DIR}/.deployment_in_progress"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
# Marker file operations use docker exec because saves directory is owned by root (Docker).
|
||||
# We run commands inside a container that has the saves directory mounted.
|
||||
create_deployment_marker() {
|
||||
local deploy_id=$1
|
||||
local container=$2 # Container to use for file operations
|
||||
docker exec "${container}" rm -f /app/saves/.flush_complete
|
||||
docker exec "${container}" sh -c "echo '${deploy_id}' > /app/saves/.deployment_in_progress"
|
||||
}
|
||||
|
||||
create_flush_marker() {
|
||||
local deploy_id=$1
|
||||
local container=$2 # Container to use for file operations
|
||||
docker exec "${container}" sh -c "echo '${deploy_id}' > /app/saves/.flush_complete"
|
||||
docker exec "${container}" rm -f /app/saves/.deployment_in_progress
|
||||
}
|
||||
|
||||
cleanup_markers_on_failure() {
|
||||
local container=$1 # Container to use for file operations
|
||||
docker exec "${container}" rm -f /app/saves/.deployment_in_progress 2>/dev/null || true
|
||||
docker exec "${container}" touch /app/saves/.flush_complete 2>/dev/null || true
|
||||
}
|
||||
|
||||
remove_stale_deployment_marker() {
|
||||
# Try any running eagle container
|
||||
local container
|
||||
container=$(docker ps --filter "name=eagle-" --format "{{.Names}}" | head -1)
|
||||
if [ -n "${container}" ]; then
|
||||
docker exec "${container}" rm -f /app/saves/.deployment_in_progress 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Determine which instance is currently running (not from nginx config)
|
||||
get_running_instance() {
|
||||
local blue_running green_running
|
||||
blue_running=$(docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null || echo "false")
|
||||
green_running=$(docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null || echo "false")
|
||||
|
||||
if [ "$blue_running" = "true" ] && [ "$green_running" = "true" ]; then
|
||||
# Both running - use nginx config to determine primary
|
||||
if grep -q "server eagle-blue:40032;" "${NGINX_CONF}" | head -1 | grep -qv backup; then
|
||||
echo "blue"
|
||||
else
|
||||
echo "green"
|
||||
fi
|
||||
elif [ "$blue_running" = "true" ]; then
|
||||
echo "blue"
|
||||
elif [ "$green_running" = "true" ]; then
|
||||
echo "green"
|
||||
else
|
||||
# Neither running - default to blue (first deploy or recovery)
|
||||
echo "none"
|
||||
fi
|
||||
}
|
||||
|
||||
# Pull image with retry using crane (handles OCI/Docker digest mismatch)
|
||||
pull_with_retry() {
|
||||
local image=$1
|
||||
local max_attempts=${2:-3}
|
||||
local attempt=1
|
||||
|
||||
# Skip pull if image already exists locally (e.g., CI already pulled it)
|
||||
if docker image inspect "${image}" &>/dev/null; then
|
||||
log_info "Image ${image} already exists locally, skipping pull"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Use crane if available (handles OCI format correctly)
|
||||
if [ -x "${APP_DIR}/crane" ]; then
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
log_info "Pulling image with crane (attempt ${attempt}/${max_attempts})..."
|
||||
if "${APP_DIR}/crane" pull "${image}" /tmp/image.tar && docker load -i /tmp/image.tar; then
|
||||
rm -f /tmp/image.tar
|
||||
log_info "Image pulled and loaded successfully"
|
||||
return 0
|
||||
fi
|
||||
rm -f /tmp/image.tar
|
||||
log_warn "Pull failed, retrying in 5 seconds..."
|
||||
sleep 5
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
else
|
||||
# Fallback to docker pull if crane not available
|
||||
log_warn "crane not found at ${APP_DIR}/crane, falling back to docker pull"
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
log_info "Pulling image (attempt ${attempt}/${max_attempts})..."
|
||||
if docker pull "${image}"; then
|
||||
log_info "Image pulled successfully"
|
||||
return 0
|
||||
fi
|
||||
log_warn "Pull failed, retrying in 5 seconds..."
|
||||
sleep 5
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
fi
|
||||
log_error "Failed to pull image after ${max_attempts} attempts"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Wait for a container to be healthy
|
||||
wait_for_healthy() {
|
||||
local container=$1
|
||||
local max_attempts=${2:-60}
|
||||
local attempt=1
|
||||
|
||||
log_info "Waiting for ${container} to become healthy..."
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
health=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "unknown")
|
||||
if [ "$health" = "healthy" ]; then
|
||||
log_info "${container} is healthy"
|
||||
return 0
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 2
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
echo ""
|
||||
log_error "${container} did not become healthy after $((max_attempts * 2)) seconds"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Main deployment logic
|
||||
main() {
|
||||
local new_tag="${1:-latest}"
|
||||
local registry="registry.digitalocean.com/eagle0/eagle-server"
|
||||
local new_image="${registry}:${new_tag}"
|
||||
|
||||
# Generate deployment ID for log correlation with server logs
|
||||
local deploy_id
|
||||
deploy_id=$(date +%s)
|
||||
local deploy_start_time=$deploy_id
|
||||
|
||||
log_info "========================================="
|
||||
log_info "Starting blue-green deployment"
|
||||
log_info "Deployment ID: ${deploy_id}"
|
||||
log_info "New image: ${new_image}"
|
||||
log_info "========================================="
|
||||
|
||||
cd "${APP_DIR}"
|
||||
|
||||
# Determine current active instance (need this before creating marker)
|
||||
local active=$(get_running_instance)
|
||||
local staging
|
||||
if [ "$active" = "blue" ] || [ "$active" = "none" ]; then
|
||||
staging="green"
|
||||
active="blue" # Normalize "none" to "blue" for first deploy
|
||||
else
|
||||
staging="blue"
|
||||
fi
|
||||
|
||||
# Step 1: Signal deployment in progress
|
||||
log_info "[DEPLOY:${deploy_id}] Step 1: Signaling deployment in progress..."
|
||||
# Use active container for marker operations (it's the one currently running)
|
||||
if [ "$active" != "none" ] && docker ps --filter "name=eagle-${active}" --format "{{.Names}}" | grep -q .; then
|
||||
create_deployment_marker "${deploy_id}" "eagle-${active}"
|
||||
log_info "[DEPLOY:${deploy_id}] Deployment marker created via eagle-${active}"
|
||||
else
|
||||
log_warn "[DEPLOY:${deploy_id}] No running container to create marker (first deploy?)"
|
||||
fi
|
||||
|
||||
# Pull the new image (with retry for intermittent registry issues)
|
||||
if ! pull_with_retry "${new_image}" 3; then
|
||||
log_error "Failed to pull new image, aborting deployment"
|
||||
cleanup_markers_on_failure "eagle-${active}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 2: Start staging instance with new image
|
||||
log_info "Step 2: Starting eagle-${staging} with new image..."
|
||||
if [ "$staging" = "green" ]; then
|
||||
EAGLE_IMAGE_NEW="${new_image}" docker compose -f "${COMPOSE_FILE}" --profile blue-green up -d eagle-green jfr-sidecar-green
|
||||
else
|
||||
EAGLE_IMAGE="${new_image}" docker compose -f "${COMPOSE_FILE}" up -d eagle-blue jfr-sidecar
|
||||
fi
|
||||
|
||||
# Wait for staging to be healthy
|
||||
if ! wait_for_healthy "eagle-${staging}" 90; then
|
||||
log_error "Staging instance failed health check, aborting deployment"
|
||||
docker compose -f "${COMPOSE_FILE}" stop "eagle-${staging}"
|
||||
cleanup_markers_on_failure "eagle-${active}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 3: Run warmup/smoke test
|
||||
local staging_port
|
||||
if [ "$staging" = "green" ]; then
|
||||
staging_port=40034
|
||||
else
|
||||
staging_port=40032
|
||||
fi
|
||||
|
||||
log_info "Step 3: Running warmup against eagle-${staging}..."
|
||||
if [ -x "${WARMUP_SCRIPT}" ]; then
|
||||
if ! "${WARMUP_SCRIPT}" "localhost:${staging_port}"; then
|
||||
log_error "Warmup/smoke test failed, aborting deployment"
|
||||
docker compose -f "${COMPOSE_FILE}" stop "eagle-${staging}"
|
||||
cleanup_markers_on_failure "eagle-${active}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
log_warn "Warmup script not found at ${WARMUP_SCRIPT}, skipping warmup"
|
||||
log_warn "JIT will be cold on first requests"
|
||||
fi
|
||||
|
||||
# Step 4: Switch nginx to staging BEFORE stopping active
|
||||
# This achieves zero downtime - users immediately route to staging.
|
||||
# Any lazy-loads will wait for the flush marker (created in step 6).
|
||||
local nginx_switch_start
|
||||
nginx_switch_start=$(date +%s)
|
||||
log_info "[DEPLOY:${deploy_id}] Step 4: Switching nginx to eagle-${staging}..."
|
||||
|
||||
# Update nginx config (variable-based routing)
|
||||
if [ "$staging" = "green" ]; then
|
||||
sed -i.bak 's/eagle-blue:40032/eagle-green:40032/g' "${NGINX_CONF}"
|
||||
else
|
||||
sed -i.bak 's/eagle-green:40032/eagle-blue:40032/g' "${NGINX_CONF}"
|
||||
fi
|
||||
|
||||
# Recreate nginx to pick up new config
|
||||
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate nginx
|
||||
|
||||
# Verify nginx picked up the correct config
|
||||
local nginx_backend
|
||||
nginx_backend=$(docker exec nginx grep -o 'eagle-[a-z]*:40032' /etc/nginx/nginx.conf | head -1 || echo "unknown")
|
||||
if [ "$nginx_backend" = "eagle-${staging}:40032" ]; then
|
||||
log_info "[DEPLOY:${deploy_id}] Verified: nginx routing to eagle-${staging}"
|
||||
else
|
||||
log_error "[DEPLOY:${deploy_id}] nginx config mismatch! Expected eagle-${staging}:40032, got ${nginx_backend}"
|
||||
exit 1
|
||||
fi
|
||||
log_info "[DEPLOY:${deploy_id}] Traffic switched to eagle-${staging} (lazy-loads will wait for flush marker)"
|
||||
local nginx_switch_end
|
||||
nginx_switch_end=$(date +%s)
|
||||
|
||||
# Step 5: Stop active instance (blocks until exit, ensuring flush completes)
|
||||
# Users may be lazy-loading on staging during this time - they'll wait for the marker.
|
||||
local flush_start
|
||||
flush_start=$(date +%s)
|
||||
log_info "[DEPLOY:${deploy_id}] Step 5: Stopping eagle-${active} (waiting for flush)..."
|
||||
docker compose -f "${COMPOSE_FILE}" stop "eagle-${active}"
|
||||
local flush_end
|
||||
flush_end=$(date +%s)
|
||||
local flush_duration=$((flush_end - flush_start))
|
||||
log_info "[DEPLOY:${deploy_id}] eagle-${active} stopped, flush completed in ${flush_duration}s"
|
||||
|
||||
# Step 6: Create flush marker - signals that disk state is fresh
|
||||
# Any waiting lazy-loads on staging will now proceed with fresh data.
|
||||
# The Eagle server automatically detects the flush marker update and invalidates any stale cached games.
|
||||
log_info "[DEPLOY:${deploy_id}] Step 6: Creating flush marker..."
|
||||
create_flush_marker "${deploy_id}" "eagle-${staging}"
|
||||
log_info "[DEPLOY:${deploy_id}] Flush marker created - server will auto-invalidate stale cache"
|
||||
|
||||
# Update .env for admin service
|
||||
local env_file="${APP_DIR}/.env"
|
||||
if [ "$staging" = "green" ]; then
|
||||
log_info "Updating .env for green instance..."
|
||||
sed -i.bak '/^EAGLE_ADDR=/d; /^JFR_SIDECAR_ADDR=/d' "${env_file}" 2>/dev/null || true
|
||||
echo "EAGLE_ADDR=eagle-green:40032" >> "${env_file}"
|
||||
echo "JFR_SIDECAR_ADDR=jfr-sidecar-green:8081" >> "${env_file}"
|
||||
else
|
||||
log_info "Updating .env for blue instance..."
|
||||
sed -i.bak '/^EAGLE_ADDR=/d; /^JFR_SIDECAR_ADDR=/d' "${env_file}" 2>/dev/null || true
|
||||
echo "EAGLE_ADDR=eagle-blue:40032" >> "${env_file}"
|
||||
echo "JFR_SIDECAR_ADDR=jfr-sidecar:8081" >> "${env_file}"
|
||||
fi
|
||||
|
||||
# Restart admin to pick up new .env
|
||||
log_info "Restarting admin service..."
|
||||
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate admin
|
||||
|
||||
# Clean up old instance
|
||||
log_info "Cleaning up old eagle-${active}..."
|
||||
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${active}" 2>/dev/null || true
|
||||
|
||||
# Stop the old jfr-sidecar (it can't attach to removed container anyway)
|
||||
if [ "$active" = "green" ]; then
|
||||
docker compose -f "${COMPOSE_FILE}" stop "jfr-sidecar-green" 2>/dev/null || true
|
||||
docker compose -f "${COMPOSE_FILE}" rm -f "jfr-sidecar-green" 2>/dev/null || true
|
||||
else
|
||||
docker compose -f "${COMPOSE_FILE}" stop "jfr-sidecar" 2>/dev/null || true
|
||||
docker compose -f "${COMPOSE_FILE}" rm -f "jfr-sidecar" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
local deploy_end_time
|
||||
deploy_end_time=$(date +%s)
|
||||
local total_duration=$((deploy_end_time - deploy_start_time))
|
||||
local user_wait_window=$((flush_end - nginx_switch_end))
|
||||
|
||||
log_info ""
|
||||
log_info "========================================="
|
||||
log_info "[DEPLOY:${deploy_id}] Deployment complete!"
|
||||
log_info " Active instance: eagle-${staging}"
|
||||
log_info " Total duration: ${total_duration}s"
|
||||
log_info " Flush duration: ${flush_duration}s"
|
||||
log_info " Max user wait window: ${user_wait_window}s"
|
||||
log_info "========================================="
|
||||
}
|
||||
|
||||
# Check for required tools
|
||||
check_requirements() {
|
||||
if ! command -v docker &> /dev/null; then
|
||||
log_error "docker is required but not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v sed &> /dev/null; then
|
||||
log_error "sed is required but not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "${NGINX_CONF}" ]; then
|
||||
log_error "nginx config not found at ${NGINX_CONF}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "${COMPOSE_FILE}" ]; then
|
||||
log_error "docker-compose file not found at ${COMPOSE_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure saves directory exists
|
||||
if [ ! -d "${SAVES_DIR}" ]; then
|
||||
log_info "Creating saves directory at ${SAVES_DIR}"
|
||||
mkdir -p "${SAVES_DIR}"
|
||||
fi
|
||||
|
||||
# Clean up any stale deployment-in-progress marker from a previous failed deploy
|
||||
if [ -f "${DEPLOYMENT_IN_PROGRESS}" ]; then
|
||||
log_warn "Found stale deployment-in-progress marker, removing it"
|
||||
remove_stale_deployment_marker
|
||||
fi
|
||||
}
|
||||
|
||||
# Run
|
||||
check_requirements
|
||||
main "$@"
|
||||
@@ -1,10 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1pv-WMXReccddPwev_YG9IXEGznuGHrYjNNEZ0Rb-ZhM/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/shardok/settings.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1p6I5nUMcoAPHIcqikVgbBCFVnqN9dpOEVClbS_wOI7M/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/eagle/settings.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1pv-WMXReccddPwev_YG9IXEGznuGHrYjNNEZ0Rb-ZhM/export?gid=0&format=tsv" > src/main/resources/net/eagle0/shardok/settings.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1p6I5nUMcoAPHIcqikVgbBCFVnqN9dpOEVClbS_wOI7M/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/settings.tsv
|
||||
|
||||
bazel run //src/main/go/net/eagle0/build/settings_generator:settings_generator -- \
|
||||
${PWD}/src/main/resources/net/eagle0/eagle/settings.tsv \
|
||||
${PWD}/src/main/scala/net/eagle0/eagle/library/settings/
|
||||
bazel run gazelle
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1DHEsiv4cY4gE6AX3sVH82K__mpBD1aznIYCQwQxA_F0/export?gid=0&format=tsv" | tr -d '\r' > /tmp/names.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1DHEsiv4cY4gE6AX3sVH82K__mpBD1aznIYCQwQxA_F0/export?gid=0&format=tsv" > /tmp/names.tsv
|
||||
bazel run //src/main/scala/net/eagle0/util:name_list_checker -- /tmp/names.tsv > src/main/resources/net/eagle0/names.tsv
|
||||
bazel run //src/main/scala/net/eagle0/util:name_list_json_maker -- /tmp/names.tsv > src/main/resources/net/eagle0/names.json
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1NhvG73HKyVE36yGpkV2oJiSIXoNqQOYTr5ArLnucYL0/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/shardok/battalionTypes.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1pNWiyxIks2wJ1v7jRLFD24zrKHG2AfhC-nkWmQKQGN4/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/eagle/heroes.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1RUguq5eAQprsZwOOqiCc-1dg4Urc_6iJ6awZsFU4MeI/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/eagle/beasts.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1NhvG73HKyVE36yGpkV2oJiSIXoNqQOYTr5ArLnucYL0/export?gid=0&format=tsv" > src/main/resources/net/eagle0/shardok/battalionTypes.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1pNWiyxIks2wJ1v7jRLFD24zrKHG2AfhC-nkWmQKQGN4/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/heroes.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1RUguq5eAQprsZwOOqiCc-1dg4Urc_6iJ6awZsFU4MeI/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/beasts.tsv
|
||||
#curl -L "https://docs.google.com/spreadsheets/d/1Z-60cJ_N1IasvqpVb5awKEkIYznEeR2IZSdli47oW88/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/province_map.tsv
|
||||
|
||||
${PWD}/scripts/dlSettings.sh
|
||||
|
||||
@@ -1,473 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# generate_changelog.sh
|
||||
#
|
||||
# Generates a weekly changelog from merged PRs, uses Claude to create a synopsis,
|
||||
# and sends an HTML email via Fastmail JMAP API.
|
||||
#
|
||||
# Usage: ./scripts/generate_changelog.sh [--dry-run]
|
||||
#
|
||||
# Configuration files (in ~/.config/eagle0/):
|
||||
# fastmail_token - API token (required)
|
||||
# changelog_recipient - Email addresses, one per line (optional, defaults to sender)
|
||||
#
|
||||
# To set up:
|
||||
# mkdir -p ~/.config/eagle0
|
||||
# echo 'your-token' > ~/.config/eagle0/fastmail_token
|
||||
# chmod 600 ~/.config/eagle0/fastmail_token
|
||||
#
|
||||
# # Optional: configure recipients (one per line, # for comments)
|
||||
# cat > ~/.config/eagle0/changelog_recipient << EOF
|
||||
# alice@example.com
|
||||
# bob@example.com
|
||||
# EOF
|
||||
#
|
||||
# The script tracks its last run using a git tag 'changelog-last-run'.
|
||||
# On first run (no tag), it defaults to the previous Friday at 4pm.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Ensure homebrew binaries are in PATH
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
TAG_NAME="changelog-last-run"
|
||||
DRY_RUN=false
|
||||
FASTMAIL_API="https://api.fastmail.com/jmap/api/"
|
||||
CONFIG_DIR="$HOME/.config/eagle0"
|
||||
TOKEN_FILE="$CONFIG_DIR/fastmail_token"
|
||||
RECIPIENT_FILE="$CONFIG_DIR/changelog_recipient"
|
||||
|
||||
# Load API token from file or environment
|
||||
load_api_token() {
|
||||
# Environment variable takes precedence
|
||||
if [[ -n "${FASTMAIL_API_TOKEN:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Try loading from config file
|
||||
if [[ -f "$TOKEN_FILE" ]]; then
|
||||
FASTMAIL_API_TOKEN=$(cat "$TOKEN_FILE" | tr -d '[:space:]')
|
||||
if [[ -n "$FASTMAIL_API_TOKEN" ]]; then
|
||||
echo "Loaded API token from $TOKEN_FILE"
|
||||
export FASTMAIL_API_TOKEN
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Load recipient emails from config file (one per line)
|
||||
# Returns JSON array fragment like: {"email": "a@b.com"}, {"email": "c@d.com"}
|
||||
load_recipients_json() {
|
||||
local recipients=""
|
||||
if [[ -f "$RECIPIENT_FILE" ]]; then
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
# Skip empty lines and comments
|
||||
line=$(echo "$line" | tr -d '[:space:]')
|
||||
[[ -z "$line" || "$line" == \#* ]] && continue
|
||||
|
||||
if [[ -n "$recipients" ]]; then
|
||||
recipients="$recipients, "
|
||||
fi
|
||||
recipients="$recipients{\"email\": \"$line\"}"
|
||||
done < "$RECIPIENT_FILE"
|
||||
fi
|
||||
echo "$recipients"
|
||||
}
|
||||
|
||||
# Get human-readable list of recipients
|
||||
load_recipients_display() {
|
||||
if [[ -f "$RECIPIENT_FILE" ]]; then
|
||||
grep -v '^#' "$RECIPIENT_FILE" | grep -v '^[[:space:]]*$' | tr '\n' ', ' | sed 's/, $//'
|
||||
fi
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Usage: $0 [--dry-run]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Get the cutoff date - either from tag or previous Friday 4pm
|
||||
get_cutoff_date() {
|
||||
# Try to get the date from the tag
|
||||
if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then
|
||||
# Get the commit date of the tagged commit
|
||||
git log -1 --format="%aI" "$TAG_NAME"
|
||||
else
|
||||
# Calculate previous Friday at 4pm
|
||||
# Get current day of week (1=Monday, 7=Sunday)
|
||||
local dow=$(date +%u)
|
||||
local days_since_friday
|
||||
|
||||
if [[ $dow -ge 5 ]]; then
|
||||
# Friday (5), Saturday (6), or Sunday (7)
|
||||
days_since_friday=$((dow - 5))
|
||||
else
|
||||
# Monday (1) through Thursday (4)
|
||||
days_since_friday=$((dow + 2))
|
||||
fi
|
||||
|
||||
# Get previous Friday at 4pm in ISO format
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
date -v-"${days_since_friday}d" -v16H -v0M -v0S +"%Y-%m-%dT%H:%M:%S%z"
|
||||
else
|
||||
date -d "$days_since_friday days ago 16:00:00" --iso-8601=seconds
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Fetch merged PRs since the cutoff date
|
||||
fetch_merged_prs() {
|
||||
local since_date="$1"
|
||||
local output_file="$2"
|
||||
|
||||
echo "Fetching PRs merged since: $since_date"
|
||||
|
||||
# Use gh to search for merged PRs
|
||||
gh pr list \
|
||||
--state merged \
|
||||
--base main \
|
||||
--json number,title,body,mergedAt,author \
|
||||
--jq ".[] | select(.mergedAt >= \"$since_date\")" \
|
||||
> "$output_file.json"
|
||||
|
||||
# Format the output nicely
|
||||
echo "# Merged PRs since $since_date" > "$output_file"
|
||||
echo "" >> "$output_file"
|
||||
|
||||
# Process each PR
|
||||
jq -r '
|
||||
"## PR #\(.number): \(.title)\n" +
|
||||
"Author: \(.author.login)\n" +
|
||||
"Merged: \(.mergedAt)\n\n" +
|
||||
"### Description\n" +
|
||||
(.body // "(No description)") +
|
||||
"\n\n---\n"
|
||||
' "$output_file.json" >> "$output_file"
|
||||
|
||||
# Count PRs
|
||||
local pr_count=$(jq -s 'length' "$output_file.json")
|
||||
echo "Found $pr_count merged PRs"
|
||||
|
||||
rm -f "$output_file.json"
|
||||
|
||||
if [[ $pr_count -eq 0 ]]; then
|
||||
echo "No PRs found since $since_date"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Generate synopsis using Claude
|
||||
generate_synopsis() {
|
||||
local input_file="$1"
|
||||
local output_file="$2"
|
||||
|
||||
echo "Generating synopsis with Claude..."
|
||||
|
||||
# Create a prompt file to avoid shell escaping issues
|
||||
local prompt_file="/tmp/eagle0_prompt_$$.txt"
|
||||
# Get repo URL for PR links
|
||||
local repo_url=$(gh repo view --json url -q '.url')
|
||||
|
||||
cat > "$prompt_file" <<PROMPT_HEADER
|
||||
You are summarizing changes for a weekly engineering update email.
|
||||
|
||||
Read the following list of merged PRs and create a concise synopsis grouped by theme/feature/area of the codebase.
|
||||
|
||||
Structure:
|
||||
1. <h1> title (e.g., "Eagle0 Weekly Update")
|
||||
2. <h2>BLUF</h2> (Bottom Line Up Front) - A short prose paragraph (2-4 sentences) highlighting the 1-3 most important changes this week and what to look for when testing. This should be conversational and help readers quickly understand what matters most.
|
||||
3. Synopsis sections (<h2> headings with bullet point summaries)
|
||||
4. <hr> divider
|
||||
5. <h2>PR Details</h2> with the same groupings, but smaller (<h3> headings) and listing PR links
|
||||
- Format each PR as: <a href="${repo_url}/pull/NUMBER">#NUMBER</a>: Title
|
||||
|
||||
Guidelines for the SYNOPSIS sections:
|
||||
- Group related changes together under clear headings (use <h2> tags)
|
||||
- Use bullet points (<ul><li>) for individual changes
|
||||
- Highlight any significant new features, breaking changes, or important fixes
|
||||
- Keep the tone professional but accessible
|
||||
- Don't include PR numbers in the synopsis - focus on what changed and why it matters
|
||||
|
||||
IMPORTANT: Output valid HTML that can be used directly in an email body. Do NOT wrap in \`\`\`html code blocks - just output the raw HTML.
|
||||
|
||||
Here are the merged PRs:
|
||||
|
||||
PROMPT_HEADER
|
||||
|
||||
cat "$input_file" >> "$prompt_file"
|
||||
echo "" >> "$prompt_file"
|
||||
echo "Generate the synopsis now:" >> "$prompt_file"
|
||||
|
||||
# Use Claude CLI to generate the synopsis, wrapped in proper HTML with charset
|
||||
local raw_output="/tmp/eagle0_raw_$$.html"
|
||||
cat "$prompt_file" | claude --print > "$raw_output"
|
||||
|
||||
# Wrap in HTML document with UTF-8 charset
|
||||
cat > "$output_file" <<'HTML_HEAD'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
<body>
|
||||
HTML_HEAD
|
||||
cat "$raw_output" >> "$output_file"
|
||||
echo "</body></html>" >> "$output_file"
|
||||
|
||||
rm -f "$prompt_file" "$raw_output"
|
||||
echo "Synopsis generated at: $output_file"
|
||||
}
|
||||
|
||||
# Get Fastmail session info (account ID, identity ID, drafts mailbox ID)
|
||||
get_fastmail_session() {
|
||||
echo "Fetching Fastmail session info..." >&2
|
||||
|
||||
# Get session
|
||||
local session=$(curl -s \
|
||||
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
|
||||
"https://api.fastmail.com/jmap/session")
|
||||
|
||||
# Extract account ID (first account)
|
||||
FASTMAIL_ACCOUNT_ID=$(echo "$session" | jq -r '.primaryAccounts["urn:ietf:params:jmap:mail"]')
|
||||
|
||||
if [[ -z "$FASTMAIL_ACCOUNT_ID" || "$FASTMAIL_ACCOUNT_ID" == "null" ]]; then
|
||||
echo "Error: Could not get Fastmail account ID. Check your API token." >&2
|
||||
return 1
|
||||
fi
|
||||
echo "Account ID: $FASTMAIL_ACCOUNT_ID" >&2
|
||||
|
||||
# Get identity ID
|
||||
local identity_response=$(curl -s \
|
||||
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST \
|
||||
-d "{
|
||||
\"using\": [\"urn:ietf:params:jmap:core\", \"urn:ietf:params:jmap:mail\", \"urn:ietf:params:jmap:submission\"],
|
||||
\"methodCalls\": [
|
||||
[\"Identity/get\", {\"accountId\": \"$FASTMAIL_ACCOUNT_ID\"}, \"0\"]
|
||||
]
|
||||
}" \
|
||||
"$FASTMAIL_API")
|
||||
|
||||
FASTMAIL_IDENTITY_ID=$(echo "$identity_response" | jq -r '.methodResponses[0][1].list[0].id')
|
||||
FASTMAIL_FROM_EMAIL=$(echo "$identity_response" | jq -r '.methodResponses[0][1].list[0].email')
|
||||
|
||||
if [[ -z "$FASTMAIL_IDENTITY_ID" || "$FASTMAIL_IDENTITY_ID" == "null" ]]; then
|
||||
echo "Error: Could not get Fastmail identity ID." >&2
|
||||
return 1
|
||||
fi
|
||||
echo "Identity ID: $FASTMAIL_IDENTITY_ID (${FASTMAIL_FROM_EMAIL})" >&2
|
||||
|
||||
# Get drafts mailbox ID
|
||||
local mailbox_response=$(curl -s \
|
||||
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST \
|
||||
-d "{
|
||||
\"using\": [\"urn:ietf:params:jmap:core\", \"urn:ietf:params:jmap:mail\"],
|
||||
\"methodCalls\": [
|
||||
[\"Mailbox/query\", {\"accountId\": \"$FASTMAIL_ACCOUNT_ID\", \"filter\": {\"role\": \"drafts\"}}, \"0\"]
|
||||
]
|
||||
}" \
|
||||
"$FASTMAIL_API")
|
||||
|
||||
FASTMAIL_DRAFTS_ID=$(echo "$mailbox_response" | jq -r '.methodResponses[0][1].ids[0]')
|
||||
|
||||
if [[ -z "$FASTMAIL_DRAFTS_ID" || "$FASTMAIL_DRAFTS_ID" == "null" ]]; then
|
||||
echo "Error: Could not get Fastmail drafts mailbox ID." >&2
|
||||
return 1
|
||||
fi
|
||||
echo "Drafts mailbox ID: $FASTMAIL_DRAFTS_ID" >&2
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Send email via Fastmail JMAP API
|
||||
send_email_fastmail() {
|
||||
local synopsis_file="$1"
|
||||
local recipients_json="$2" # JSON array fragment: {"email": "a@b.com"}, {"email": "c@d.com"}
|
||||
|
||||
local subject="Eagle0 Weekly Changelog - $(date +%Y-%m-%d)"
|
||||
local html_body=$(cat "$synopsis_file" | jq -Rs .)
|
||||
|
||||
echo "Sending email via Fastmail JMAP API..."
|
||||
|
||||
# Create the email and send it in one request
|
||||
local response=$(curl -s \
|
||||
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST \
|
||||
-d "{
|
||||
\"using\": [
|
||||
\"urn:ietf:params:jmap:core\",
|
||||
\"urn:ietf:params:jmap:mail\",
|
||||
\"urn:ietf:params:jmap:submission\"
|
||||
],
|
||||
\"methodCalls\": [
|
||||
[\"Email/set\", {
|
||||
\"accountId\": \"$FASTMAIL_ACCOUNT_ID\",
|
||||
\"create\": {
|
||||
\"draft\": {
|
||||
\"from\": [{\"email\": \"$FASTMAIL_FROM_EMAIL\"}],
|
||||
\"to\": [$recipients_json],
|
||||
\"subject\": \"$subject\",
|
||||
\"mailboxIds\": {\"$FASTMAIL_DRAFTS_ID\": true},
|
||||
\"keywords\": {\"\$draft\": true},
|
||||
\"htmlBody\": [{\"partId\": \"body\", \"type\": \"text/html\"}],
|
||||
\"bodyValues\": {
|
||||
\"body\": {
|
||||
\"charset\": \"utf-8\",
|
||||
\"value\": $html_body
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, \"0\"],
|
||||
[\"EmailSubmission/set\", {
|
||||
\"accountId\": \"$FASTMAIL_ACCOUNT_ID\",
|
||||
\"onSuccessDestroyEmail\": [\"#sendIt\"],
|
||||
\"create\": {
|
||||
\"sendIt\": {
|
||||
\"emailId\": \"#draft\",
|
||||
\"identityId\": \"$FASTMAIL_IDENTITY_ID\"
|
||||
}
|
||||
}
|
||||
}, \"1\"]
|
||||
]
|
||||
}" \
|
||||
"$FASTMAIL_API")
|
||||
|
||||
# Check for errors
|
||||
local error=$(echo "$response" | jq -r '.methodResponses[0][1].notCreated.draft.description // empty')
|
||||
if [[ -n "$error" ]]; then
|
||||
echo "Error creating email: $error" >&2
|
||||
echo "Full response: $response" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local send_error=$(echo "$response" | jq -r '.methodResponses[1][1].notCreated.sendIt.description // empty')
|
||||
if [[ -n "$send_error" ]]; then
|
||||
echo "Error sending email: $send_error" >&2
|
||||
echo "Full response: $response" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Email sent successfully"
|
||||
}
|
||||
|
||||
# Update the tag to mark this run
|
||||
update_tag() {
|
||||
echo "Updating $TAG_NAME tag..."
|
||||
|
||||
# Delete existing tag if present
|
||||
git tag -d "$TAG_NAME" 2>/dev/null || true
|
||||
git push origin --delete "$TAG_NAME" 2>/dev/null || true
|
||||
|
||||
# Create new tag at HEAD
|
||||
git tag "$TAG_NAME"
|
||||
git push origin "$TAG_NAME"
|
||||
|
||||
echo "Tag updated to current HEAD"
|
||||
}
|
||||
|
||||
# Main
|
||||
main() {
|
||||
echo "=== Eagle0 Weekly Changelog Generator ==="
|
||||
echo ""
|
||||
|
||||
# Load API token (only required for actual send)
|
||||
if [[ "$DRY_RUN" != "true" ]]; then
|
||||
if ! load_api_token; then
|
||||
echo "Error: No Fastmail API token found."
|
||||
echo ""
|
||||
echo "To create a token:"
|
||||
echo "1. Go to Fastmail Settings -> Password & Security -> API tokens"
|
||||
echo "2. Create a new token with 'Email submission' scope"
|
||||
echo "3. Save it using one of these methods:"
|
||||
echo ""
|
||||
echo " Option A (recommended): Store in config file"
|
||||
echo " mkdir -p ~/.config/eagle0"
|
||||
echo " echo 'your-token' > ~/.config/eagle0/fastmail_token"
|
||||
echo " chmod 600 ~/.config/eagle0/fastmail_token"
|
||||
echo ""
|
||||
echo " Option B: Set environment variable"
|
||||
echo " export FASTMAIL_API_TOKEN='your-token'"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Get cutoff date
|
||||
local cutoff_date=$(get_cutoff_date)
|
||||
echo "Cutoff date: $cutoff_date"
|
||||
|
||||
# Create temp files
|
||||
local pr_file="/tmp/eagle0_prs_$(date +%s).md"
|
||||
local synopsis_file="/tmp/eagle0_synopsis_$(date +%s).html"
|
||||
|
||||
# Fetch PRs
|
||||
if ! fetch_merged_prs "$cutoff_date" "$pr_file"; then
|
||||
echo "No changes to report. Exiting."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "PR details saved to: $pr_file"
|
||||
|
||||
# Generate synopsis
|
||||
generate_synopsis "$pr_file" "$synopsis_file"
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo ""
|
||||
echo "=== DRY RUN - Synopsis content ==="
|
||||
cat "$synopsis_file"
|
||||
echo ""
|
||||
echo "=== DRY RUN - Skipping email send and tag update ==="
|
||||
else
|
||||
# Get Fastmail session info
|
||||
if ! get_fastmail_session; then
|
||||
echo "Failed to get Fastmail session info. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine recipients (from config file, or default to sender)
|
||||
local recipients_json=$(load_recipients_json)
|
||||
if [[ -z "$recipients_json" ]]; then
|
||||
recipients_json="{\"email\": \"$FASTMAIL_FROM_EMAIL\"}"
|
||||
echo "No recipients configured, sending to self ($FASTMAIL_FROM_EMAIL)"
|
||||
else
|
||||
local recipients_display=$(load_recipients_display)
|
||||
echo "Sending to: $recipients_display"
|
||||
fi
|
||||
|
||||
# Send email
|
||||
send_email_fastmail "$synopsis_file" "$recipients_json"
|
||||
|
||||
# Update tag for next run
|
||||
update_tag
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done!"
|
||||
echo "PR details: $pr_file"
|
||||
echo "Synopsis: $synopsis_file"
|
||||
}
|
||||
|
||||
main
|
||||
@@ -1,41 +0,0 @@
|
||||
// +build ignore
|
||||
|
||||
// Script to generate Ed25519 key pair for manifest signing.
|
||||
// Run with: go run scripts/generate_manifest_keys.go
|
||||
//
|
||||
// This will output:
|
||||
// - Private key (base64): Store as MANIFEST_SIGNING_KEY GitHub secret
|
||||
// - Public key (base64): Embed in EagleInstaller for verification
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Generate Ed25519 key pair
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to generate key pair: %v", err)
|
||||
}
|
||||
|
||||
// Encode keys as base64
|
||||
privateKeyB64 := base64.StdEncoding.EncodeToString(privateKey)
|
||||
publicKeyB64 := base64.StdEncoding.EncodeToString(publicKey)
|
||||
|
||||
fmt.Println("=== Ed25519 Key Pair for Manifest Signing ===")
|
||||
fmt.Println()
|
||||
fmt.Println("PRIVATE KEY (store as GitHub secret MANIFEST_SIGNING_KEY):")
|
||||
fmt.Println(privateKeyB64)
|
||||
fmt.Println()
|
||||
fmt.Println("PUBLIC KEY (embed in EagleInstaller.cs for verification):")
|
||||
fmt.Println(publicKeyB64)
|
||||
fmt.Println()
|
||||
fmt.Printf("Private key size: %d bytes\n", len(privateKey))
|
||||
fmt.Printf("Public key size: %d bytes\n", len(publicKey))
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Inject Sparkle framework into a macOS .app bundle for auto-updates
|
||||
# Usage: inject_sparkle.sh <app_path>
|
||||
#
|
||||
# Environment variables (required):
|
||||
# SPARKLE_EDDSA_PUBLIC_KEY - EdDSA public key for verifying updates
|
||||
#
|
||||
# Optional environment variables:
|
||||
# SPARKLE_FEED_URL - Appcast URL (default: https://assets.eagle0.net/mac/appcast.xml)
|
||||
# SPARKLE_VERSION - Sparkle version to use (default: 2.6.4)
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
APP_PATH="$1"
|
||||
SPARKLE_VERSION="${SPARKLE_VERSION:-2.6.4}"
|
||||
SPARKLE_FEED_URL="${SPARKLE_FEED_URL:-https://assets.eagle0.net/mac/appcast.xml}"
|
||||
SPARKLE_CACHE_DIR="/tmp/sparkle-cache"
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
echo "ERROR: App not found at $APP_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${SPARKLE_EDDSA_PUBLIC_KEY:-}" ]; then
|
||||
echo "ERROR: SPARKLE_EDDSA_PUBLIC_KEY environment variable not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Download Sparkle if not cached
|
||||
SPARKLE_DIR="$SPARKLE_CACHE_DIR/Sparkle-$SPARKLE_VERSION"
|
||||
if [ ! -d "$SPARKLE_DIR/Sparkle.framework" ]; then
|
||||
echo "=== Downloading Sparkle $SPARKLE_VERSION ==="
|
||||
mkdir -p "$SPARKLE_CACHE_DIR"
|
||||
SPARKLE_URL="https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz"
|
||||
curl -L "$SPARKLE_URL" | tar -xJ -C "$SPARKLE_CACHE_DIR"
|
||||
mv "$SPARKLE_CACHE_DIR/Sparkle-$SPARKLE_VERSION" "$SPARKLE_DIR" 2>/dev/null || true
|
||||
# If the extracted directory doesn't match version pattern, it may just be "Sparkle"
|
||||
if [ ! -d "$SPARKLE_DIR" ]; then
|
||||
mkdir -p "$SPARKLE_DIR"
|
||||
mv "$SPARKLE_CACHE_DIR/Sparkle.framework" "$SPARKLE_DIR/" 2>/dev/null || true
|
||||
mv "$SPARKLE_CACHE_DIR/bin" "$SPARKLE_DIR/" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=== Injecting Sparkle framework ==="
|
||||
FRAMEWORKS_DIR="$APP_PATH/Contents/Frameworks"
|
||||
mkdir -p "$FRAMEWORKS_DIR"
|
||||
|
||||
# Copy Sparkle framework
|
||||
cp -R "$SPARKLE_DIR/Sparkle.framework" "$FRAMEWORKS_DIR/"
|
||||
|
||||
# Also copy the XPC services if present
|
||||
if [ -d "$SPARKLE_DIR/Sparkle.framework/Versions/B/XPCServices" ]; then
|
||||
echo "Sparkle XPC services present"
|
||||
fi
|
||||
|
||||
echo "=== Updating Info.plist ==="
|
||||
PLIST_PATH="$APP_PATH/Contents/Info.plist"
|
||||
|
||||
# Add Sparkle configuration to Info.plist
|
||||
/usr/libexec/PlistBuddy -c "Delete :SUFeedURL" "$PLIST_PATH" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Add :SUFeedURL string '$SPARKLE_FEED_URL'" "$PLIST_PATH"
|
||||
|
||||
/usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$PLIST_PATH" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string '$SPARKLE_EDDSA_PUBLIC_KEY'" "$PLIST_PATH"
|
||||
|
||||
/usr/libexec/PlistBuddy -c "Delete :SUEnableAutomaticChecks" "$PLIST_PATH" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Add :SUEnableAutomaticChecks bool true" "$PLIST_PATH"
|
||||
|
||||
# Set bundle version from git for Sparkle version comparison
|
||||
VERSION=$(git describe --tags --always 2>/dev/null || echo "1.0.0")
|
||||
BUILD_NUMBER=$(git rev-list --count HEAD 2>/dev/null || echo "1")
|
||||
|
||||
echo "Setting version: $VERSION (build $BUILD_NUMBER)"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$PLIST_PATH" 2>/dev/null || \
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string '$VERSION'" "$PLIST_PATH"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST_PATH" 2>/dev/null || \
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleVersion string '$BUILD_NUMBER'" "$PLIST_PATH"
|
||||
|
||||
# Add URL scheme for invitation codes (eagle0://invite?code=XXXX)
|
||||
echo "=== Adding URL scheme for invitation codes ==="
|
||||
/usr/libexec/PlistBuddy -c "Delete :CFBundleURLTypes" "$PLIST_PATH" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes array" "$PLIST_PATH"
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0 dict" "$PLIST_PATH"
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLName string 'com.Shardok-Games.eagle0'" "$PLIST_PATH"
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes array" "$PLIST_PATH"
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string 'eagle0'" "$PLIST_PATH"
|
||||
|
||||
echo "=== Sparkle injection complete ==="
|
||||
echo "App: $APP_PATH"
|
||||
echo "Feed URL: $SPARKLE_FEED_URL"
|
||||
echo "Version: $VERSION (build $BUILD_NUMBER)"
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Notarize a macOS .app bundle with Apple
|
||||
# Usage: notarize_mac_app.sh <app_path>
|
||||
#
|
||||
# Environment variables (required):
|
||||
# APPLE_ID - Apple Developer account email
|
||||
# APP_SPECIFIC_PASSWORD - App-specific password for notarytool
|
||||
# TEAM_ID - Apple Developer Team ID
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
APP_PATH="$1"
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
echo "ERROR: App not found at $APP_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${APPLE_ID:-}" ] || [ -z "${APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${TEAM_ID:-}" ]; then
|
||||
echo "ERROR: Required environment variables not set"
|
||||
echo " APPLE_ID: ${APPLE_ID:-<not set>}"
|
||||
echo " APP_SPECIFIC_PASSWORD: ${APP_SPECIFIC_PASSWORD:+<set>}"
|
||||
echo " TEAM_ID: ${TEAM_ID:-<not set>}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create ZIP for notarization submission
|
||||
ZIP_PATH="${APP_PATH%.app}.zip"
|
||||
echo "=== Creating ZIP for notarization: $ZIP_PATH ==="
|
||||
ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH"
|
||||
|
||||
echo "=== Submitting to Apple for notarization ==="
|
||||
SUBMIT_OUTPUT=$(xcrun notarytool submit "$ZIP_PATH" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$TEAM_ID" \
|
||||
--wait 2>&1) || true
|
||||
|
||||
echo "$SUBMIT_OUTPUT"
|
||||
|
||||
# Extract submission ID and status (look for " status:" to avoid matching "Current status:")
|
||||
SUBMISSION_ID=$(echo "$SUBMIT_OUTPUT" | grep "id:" | head -1 | awk '{print $2}')
|
||||
STATUS=$(echo "$SUBMIT_OUTPUT" | grep "^ status:" | awk '{print $2}')
|
||||
|
||||
echo "Submission ID: $SUBMISSION_ID"
|
||||
echo "Status: $STATUS"
|
||||
|
||||
# Clean up the zip (use -f to avoid failure if already deleted)
|
||||
rm -f "$ZIP_PATH"
|
||||
|
||||
if [ "$STATUS" != "Accepted" ]; then
|
||||
echo "=== Notarization failed! Fetching log for details ==="
|
||||
xcrun notarytool log "$SUBMISSION_ID" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$TEAM_ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Stapling notarization ticket to app ==="
|
||||
# Retry stapling - Apple's CloudKit can have a brief delay after notarization completes
|
||||
MAX_STAPLE_ATTEMPTS=5
|
||||
STAPLE_ATTEMPT=1
|
||||
while [ $STAPLE_ATTEMPT -le $MAX_STAPLE_ATTEMPTS ]; do
|
||||
echo "Stapling attempt $STAPLE_ATTEMPT/$MAX_STAPLE_ATTEMPTS..."
|
||||
if xcrun stapler staple "$APP_PATH"; then
|
||||
echo "Stapling successful"
|
||||
break
|
||||
fi
|
||||
if [ $STAPLE_ATTEMPT -eq $MAX_STAPLE_ATTEMPTS ]; then
|
||||
echo "ERROR: Stapling failed after $MAX_STAPLE_ATTEMPTS attempts"
|
||||
exit 1
|
||||
fi
|
||||
echo "Stapling failed, waiting 10 seconds before retry..."
|
||||
sleep 10
|
||||
STAPLE_ATTEMPT=$((STAPLE_ATTEMPT + 1))
|
||||
done
|
||||
|
||||
echo "=== Verifying notarization ==="
|
||||
xcrun stapler validate "$APP_PATH"
|
||||
spctl --assess --type exec -v "$APP_PATH"
|
||||
|
||||
echo "Notarization complete: $APP_PATH"
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Submit a macOS .app bundle to Apple for notarization (no waiting)
|
||||
# Usage: notarize_submit.sh <app_path>
|
||||
# Outputs: submission_id=<id> to stdout (for GitHub Actions)
|
||||
#
|
||||
# Environment variables (required):
|
||||
# APPLE_ID - Apple Developer account email
|
||||
# APP_SPECIFIC_PASSWORD - App-specific password for notarytool
|
||||
# TEAM_ID - Apple Developer Team ID
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APP_PATH="$1"
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
echo "ERROR: App not found at $APP_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${APPLE_ID:-}" ] || [ -z "${APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${TEAM_ID:-}" ]; then
|
||||
echo "ERROR: Required environment variables not set" >&2
|
||||
echo " APPLE_ID: ${APPLE_ID:-<not set>}" >&2
|
||||
echo " APP_SPECIFIC_PASSWORD: ${APP_SPECIFIC_PASSWORD:+<set>}" >&2
|
||||
echo " TEAM_ID: ${TEAM_ID:-<not set>}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create ZIP for notarization submission
|
||||
ZIP_PATH="${APP_PATH%.app}.zip"
|
||||
echo "=== Creating ZIP for notarization: $ZIP_PATH ===" >&2
|
||||
ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH"
|
||||
|
||||
echo "=== Submitting to Apple for notarization ===" >&2
|
||||
SUBMIT_OUTPUT=$(xcrun notarytool submit "$ZIP_PATH" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$TEAM_ID" 2>&1)
|
||||
|
||||
echo "$SUBMIT_OUTPUT" >&2
|
||||
|
||||
# Extract submission ID
|
||||
SUBMISSION_ID=$(echo "$SUBMIT_OUTPUT" | grep "id:" | head -1 | awk '{print $2}')
|
||||
|
||||
if [ -z "$SUBMISSION_ID" ]; then
|
||||
echo "ERROR: Failed to get submission ID" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean up the zip
|
||||
rm "$ZIP_PATH"
|
||||
|
||||
echo "Submission ID: $SUBMISSION_ID" >&2
|
||||
|
||||
# Output for GitHub Actions
|
||||
echo "submission_id=$SUBMISSION_ID"
|
||||
@@ -1,76 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Wait for Apple notarization to complete and staple the ticket
|
||||
# Usage: notarize_wait.sh <submission_id> <app_path>
|
||||
#
|
||||
# Environment variables (required):
|
||||
# APPLE_ID - Apple Developer account email
|
||||
# APP_SPECIFIC_PASSWORD - App-specific password for notarytool
|
||||
# TEAM_ID - Apple Developer Team ID
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SUBMISSION_ID="$1"
|
||||
APP_PATH="$2"
|
||||
|
||||
if [ -z "$SUBMISSION_ID" ]; then
|
||||
echo "ERROR: submission_id is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
echo "ERROR: App not found at $APP_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${APPLE_ID:-}" ] || [ -z "${APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${TEAM_ID:-}" ]; then
|
||||
echo "ERROR: Required environment variables not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Waiting for notarization of submission $SUBMISSION_ID ==="
|
||||
WAIT_OUTPUT=$(xcrun notarytool wait "$SUBMISSION_ID" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$TEAM_ID" 2>&1) || true
|
||||
|
||||
echo "$WAIT_OUTPUT"
|
||||
|
||||
# Extract status (look for " status:" to avoid matching "Current status:")
|
||||
STATUS=$(echo "$WAIT_OUTPUT" | grep "^ status:" | awk '{print $2}')
|
||||
|
||||
echo "Status: $STATUS"
|
||||
|
||||
if [ "$STATUS" != "Accepted" ]; then
|
||||
echo "=== Notarization failed! Fetching log for details ==="
|
||||
xcrun notarytool log "$SUBMISSION_ID" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$TEAM_ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Stapling notarization ticket to app ==="
|
||||
# Retry stapling - Apple's CloudKit can have a brief delay after notarization completes
|
||||
MAX_STAPLE_ATTEMPTS=5
|
||||
STAPLE_ATTEMPT=1
|
||||
while [ $STAPLE_ATTEMPT -le $MAX_STAPLE_ATTEMPTS ]; do
|
||||
echo "Stapling attempt $STAPLE_ATTEMPT/$MAX_STAPLE_ATTEMPTS..."
|
||||
if xcrun stapler staple "$APP_PATH"; then
|
||||
echo "Stapling successful"
|
||||
break
|
||||
fi
|
||||
if [ $STAPLE_ATTEMPT -eq $MAX_STAPLE_ATTEMPTS ]; then
|
||||
echo "ERROR: Stapling failed after $MAX_STAPLE_ATTEMPTS attempts"
|
||||
exit 1
|
||||
fi
|
||||
echo "Stapling failed, waiting 10 seconds before retry..."
|
||||
sleep 10
|
||||
STAPLE_ATTEMPT=$((STAPLE_ATTEMPT + 1))
|
||||
done
|
||||
|
||||
echo "=== Verifying notarization ==="
|
||||
xcrun stapler validate "$APP_PATH"
|
||||
spctl --assess --type exec -v "$APP_PATH"
|
||||
|
||||
echo "Notarization complete: $APP_PATH"
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Pre-commit hook wrapper for gazelle that fails if files are modified.
|
||||
# This ensures BUILD files are in canonical format before committing.
|
||||
|
||||
set -e
|
||||
|
||||
# Run gazelle
|
||||
bazel run //:gazelle 2>/dev/null
|
||||
|
||||
# Check if any BUILD files were modified
|
||||
if ! git diff --quiet -- '*.bazel' '**/BUILD' 'WORKSPACE*'; then
|
||||
echo ""
|
||||
echo "ERROR: gazelle modified BUILD files. Please stage the changes and retry:"
|
||||
echo ""
|
||||
git diff --name-only -- '*.bazel' '**/BUILD' 'WORKSPACE*'
|
||||
echo ""
|
||||
echo "Run: git add -u && git commit"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,149 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Setup script for Eagle0 production droplet
|
||||
# Run this on a fresh DigitalOcean droplet (Ubuntu 24.04)
|
||||
#
|
||||
# Usage: curl -sSL https://raw.githubusercontent.com/nolen777/eagle0/main/scripts/setup_droplet.sh | sudo bash
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DOMAIN="${DOMAIN:-eagle0.net}"
|
||||
DEPLOY_USER="${DEPLOY_USER:-deploy}"
|
||||
APP_DIR="/opt/eagle0"
|
||||
|
||||
echo "=== Eagle0 Production Server Setup ==="
|
||||
echo "Domain: ${DOMAIN}"
|
||||
echo "Deploy user: ${DEPLOY_USER}"
|
||||
echo ""
|
||||
|
||||
# Check if running as root
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This script must be run as root (use sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Updating system ==="
|
||||
apt-get update
|
||||
apt-get upgrade -y
|
||||
|
||||
echo "=== Installing Docker ==="
|
||||
if ! command -v docker &> /dev/null; then
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
systemctl enable docker
|
||||
systemctl start docker
|
||||
else
|
||||
echo "Docker already installed"
|
||||
fi
|
||||
|
||||
echo "=== Installing Docker Compose plugin ==="
|
||||
apt-get install -y docker-compose-plugin
|
||||
|
||||
echo "=== Installing additional utilities ==="
|
||||
apt-get install -y \
|
||||
curl \
|
||||
wget \
|
||||
git \
|
||||
netcat-openbsd \
|
||||
jq \
|
||||
htop \
|
||||
unattended-upgrades
|
||||
|
||||
echo "=== Configuring automatic security updates ==="
|
||||
cat > /etc/apt/apt.conf.d/20auto-upgrades << 'EOF'
|
||||
APT::Periodic::Update-Package-Lists "1";
|
||||
APT::Periodic::Unattended-Upgrade "1";
|
||||
APT::Periodic::AutocleanInterval "7";
|
||||
EOF
|
||||
|
||||
echo "=== Creating deploy user ==="
|
||||
if ! id "${DEPLOY_USER}" &>/dev/null; then
|
||||
useradd -m -s /bin/bash -G docker "${DEPLOY_USER}"
|
||||
mkdir -p "/home/${DEPLOY_USER}/.ssh"
|
||||
chmod 700 "/home/${DEPLOY_USER}/.ssh"
|
||||
chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "/home/${DEPLOY_USER}/.ssh"
|
||||
echo ""
|
||||
echo "*** IMPORTANT: Add your SSH public key to /home/${DEPLOY_USER}/.ssh/authorized_keys ***"
|
||||
echo ""
|
||||
else
|
||||
echo "User ${DEPLOY_USER} already exists"
|
||||
# Ensure user is in docker group
|
||||
usermod -aG docker "${DEPLOY_USER}"
|
||||
fi
|
||||
|
||||
echo "=== Creating application directory ==="
|
||||
mkdir -p "${APP_DIR}"/{nginx,certbot/conf,certbot/www,saves}
|
||||
chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "${APP_DIR}"
|
||||
|
||||
echo "=== Configuring Docker registry authentication ==="
|
||||
echo ""
|
||||
echo "*** IMPORTANT: Run the following command to authenticate with DigitalOcean Container Registry: ***"
|
||||
echo " docker login registry.digitalocean.com"
|
||||
echo ""
|
||||
|
||||
echo "=== Creating systemd service ==="
|
||||
cat > /etc/systemd/system/eagle0.service << EOF
|
||||
[Unit]
|
||||
Description=Eagle0 Game Servers
|
||||
Requires=docker.service
|
||||
After=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=${APP_DIR}
|
||||
ExecStart=/usr/bin/docker compose -f docker-compose.prod.yml up -d
|
||||
ExecStop=/usr/bin/docker compose -f docker-compose.prod.yml down
|
||||
User=${DEPLOY_USER}
|
||||
Group=${DEPLOY_USER}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable eagle0
|
||||
|
||||
echo "=== Configuring firewall (UFW) ==="
|
||||
if ! command -v ufw &> /dev/null; then
|
||||
apt-get install -y ufw
|
||||
fi
|
||||
|
||||
ufw default deny incoming
|
||||
ufw default allow outgoing
|
||||
ufw allow ssh
|
||||
ufw allow 80/tcp
|
||||
ufw allow 443/tcp
|
||||
ufw --force enable
|
||||
|
||||
echo "=== Setting up log rotation ==="
|
||||
cat > /etc/logrotate.d/eagle0 << EOF
|
||||
/var/log/eagle0/*.log {
|
||||
daily
|
||||
missingok
|
||||
rotate 14
|
||||
compress
|
||||
delaycompress
|
||||
notifempty
|
||||
create 0640 ${DEPLOY_USER} ${DEPLOY_USER}
|
||||
sharedscripts
|
||||
}
|
||||
EOF
|
||||
|
||||
mkdir -p /var/log/eagle0
|
||||
chown "${DEPLOY_USER}:${DEPLOY_USER}" /var/log/eagle0
|
||||
|
||||
echo ""
|
||||
echo "=== Setup Complete ==="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Add SSH public key to /home/${DEPLOY_USER}/.ssh/authorized_keys"
|
||||
echo "2. Copy docker-compose.prod.yml to ${APP_DIR}/"
|
||||
echo "3. Copy nginx/nginx.conf to ${APP_DIR}/nginx/"
|
||||
echo "4. Create .env file in ${APP_DIR}/ with OPENAI_API_KEY"
|
||||
echo "5. Run: docker login registry.digitalocean.com"
|
||||
echo "6. Get SSL certificate: (see init_ssl.sh)"
|
||||
echo "7. Start services: systemctl start eagle0"
|
||||
echo ""
|
||||
echo "Server IP: $(curl -s ifconfig.me)"
|
||||
echo ""
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
bazel run //src/main/go/net/eagle0/build/action_result_type_build_file_generator \
|
||||
${PWD}/src/main/scala/net/eagle0/eagle/model/action_result/types/
|
||||
@@ -1,143 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Warmup Script for Eagle Server
|
||||
#
|
||||
# This script warms up the JIT compiler before switching traffic to a new instance.
|
||||
# It uses the Go warmup tool which:
|
||||
# 1. Creates a test game via bidirectional streaming
|
||||
# 2. Posts an Improve command
|
||||
# 3. Verifies action results and new commands
|
||||
# 4. Cleans up the test game
|
||||
#
|
||||
# Usage: ./warmup-eagle.sh HOST:PORT
|
||||
#
|
||||
# Example:
|
||||
# ./warmup-eagle.sh localhost:40032
|
||||
# ./warmup-eagle.sh localhost:40034
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
HOST="${1:-localhost:40032}"
|
||||
|
||||
log_info "Warming up Eagle server at ${HOST}..."
|
||||
|
||||
# Try to find the Go warmup tool
|
||||
WARMUP_TOOL=""
|
||||
|
||||
# Check if we're in the project directory with bazel
|
||||
if [ -f "${PROJECT_ROOT}/WORKSPACE" ] || [ -f "${PROJECT_ROOT}/WORKSPACE.bazel" ]; then
|
||||
# Try to find the pre-built binary
|
||||
BAZEL_BIN="${PROJECT_ROOT}/bazel-bin/src/main/go/net/eagle0/warmup/warmup_/warmup"
|
||||
if [ -x "${BAZEL_BIN}" ]; then
|
||||
WARMUP_TOOL="${BAZEL_BIN}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for the warmup tool in common locations (for deployed environments)
|
||||
if [ -z "${WARMUP_TOOL}" ]; then
|
||||
for path in \
|
||||
"${SCRIPT_DIR}/bin/warmup" \
|
||||
"/opt/eagle0/scripts/bin/warmup" \
|
||||
"/opt/eagle0/bin/warmup" \
|
||||
"/usr/local/bin/eagle-warmup" \
|
||||
"${SCRIPT_DIR}/warmup"; do
|
||||
if [ -x "${path}" ]; then
|
||||
WARMUP_TOOL="${path}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# If we found the Go tool, use it
|
||||
if [ -n "${WARMUP_TOOL}" ]; then
|
||||
log_info "Using Go warmup tool: ${WARMUP_TOOL}"
|
||||
# Use 5 minute timeout to allow for slow operations on cold JVM
|
||||
if "${WARMUP_TOOL}" --address="${HOST}" --timeout=300s; then
|
||||
log_info "Warmup complete!"
|
||||
exit 0
|
||||
else
|
||||
log_error "Go warmup tool failed"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback to grpcurl-based warmup
|
||||
log_warn "Go warmup tool not found, falling back to grpcurl"
|
||||
|
||||
# Check for grpcurl
|
||||
if ! command -v grpcurl &> /dev/null; then
|
||||
log_error "Neither Go warmup tool nor grpcurl is available"
|
||||
log_error "Build the warmup tool with: bazel build //src/main/go/net/eagle0/warmup"
|
||||
log_error "Or install grpcurl: brew install grpcurl (macOS)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Warmup iterations
|
||||
WARMUP_ITERATIONS=3
|
||||
|
||||
# 1. Call GetRunningGames multiple times - this exercises the gRPC layer and basic game access
|
||||
log_info "Warming up GetRunningGames..."
|
||||
for i in $(seq 1 ${WARMUP_ITERATIONS}); do
|
||||
RESULT=$(grpcurl -plaintext -d '{}' "${HOST}" net.eagle0.eagle.api.Eagle/GetRunningGames 2>&1) || true
|
||||
if echo "$RESULT" | grep -q "games\|{}"; then
|
||||
echo -n "."
|
||||
else
|
||||
log_error "GetRunningGames failed on iteration $i"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo " done"
|
||||
|
||||
# 2. Call GetSettings - exercises settings loading
|
||||
log_info "Warming up GetSettings..."
|
||||
for i in $(seq 1 ${WARMUP_ITERATIONS}); do
|
||||
if grpcurl -plaintext -d '{}' "${HOST}" net.eagle0.eagle.api.Eagle/GetSettings > /dev/null 2>&1; then
|
||||
echo -n "."
|
||||
else
|
||||
log_warn "GetSettings failed on iteration $i (non-fatal)"
|
||||
fi
|
||||
done
|
||||
echo " done"
|
||||
|
||||
# 3. Call AddSettings with empty list - exercises settings path
|
||||
log_info "Warming up AddSettings..."
|
||||
for i in $(seq 1 ${WARMUP_ITERATIONS}); do
|
||||
if grpcurl -plaintext -d '{"settings": []}' "${HOST}" net.eagle0.eagle.api.Eagle/AddSettings > /dev/null 2>&1; then
|
||||
echo -n "."
|
||||
else
|
||||
log_warn "AddSettings failed on iteration $i (non-fatal)"
|
||||
fi
|
||||
done
|
||||
echo " done"
|
||||
|
||||
# Final health check
|
||||
log_info "Verifying server health..."
|
||||
if grpcurl -plaintext -d '{}' "${HOST}" net.eagle0.eagle.api.Eagle/GetRunningGames > /dev/null 2>&1; then
|
||||
log_info "Health check passed"
|
||||
else
|
||||
log_error "Health check failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_info ""
|
||||
log_info "Warmup complete (basic mode - bidirectional streaming warmup not available)!"
|
||||
log_info "The JIT should be warmed for:"
|
||||
log_info " - gRPC layer and protobuf parsing"
|
||||
log_info " - Settings loading and management"
|
||||
log_info ""
|
||||
log_warn "Note: For full warmup including game creation and command processing,"
|
||||
log_warn " build and use the Go warmup tool: bazel build //src/main/go/net/eagle0/warmup"
|
||||
@@ -22,6 +22,13 @@ cc_library(
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "container_utils",
|
||||
hdrs = ["ContainerUtils.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "filesystem_utils",
|
||||
srcs = ["FilesystemUtils.cpp"],
|
||||
@@ -88,13 +95,6 @@ cc_library(
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "thread_pool",
|
||||
hdrs = ["ThreadPool.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "time_utils",
|
||||
hdrs = ["TimeUtils.hpp"],
|
||||
|
||||
@@ -7,43 +7,12 @@
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// FNV-1a 64-bit constants
|
||||
constexpr uint64_t FNV_PRIME = 0x00000100000001B3ULL;
|
||||
constexpr uint64_t FNV_OFFSET_BASIS = 0xcbf29ce484222325ULL;
|
||||
constexpr uint64_t FNV_PRIME = 0x100000001b3;
|
||||
constexpr uint64_t FNV_OFFSET_BASIS = 0xcbf29ce484222325;
|
||||
|
||||
// FNV-1a algorithm: XOR first, then multiply
|
||||
static inline auto MixIn(uint64_t& hash, const uint8_t byte) {
|
||||
hash ^= byte;
|
||||
hash *= FNV_PRIME;
|
||||
}
|
||||
|
||||
// Hash an entire buffer using FNV-1a
|
||||
// Fast word-at-a-time implementation - processes 8 bytes at once for better performance
|
||||
// while maintaining good distribution properties for hash table use
|
||||
static inline auto HashBuffer(const uint8_t* data, size_t size) -> uint64_t {
|
||||
if (data == nullptr) { return FNV_OFFSET_BASIS; }
|
||||
|
||||
uint64_t hash = FNV_OFFSET_BASIS;
|
||||
const uint8_t* end = data + size;
|
||||
|
||||
// Process 8 bytes at a time
|
||||
while (data + 8 <= end) {
|
||||
uint64_t word;
|
||||
// Use memcpy to avoid alignment issues and let compiler optimize
|
||||
__builtin_memcpy(&word, data, sizeof(word));
|
||||
hash ^= word;
|
||||
hash *= FNV_PRIME;
|
||||
data += 8;
|
||||
}
|
||||
|
||||
// Process remaining bytes
|
||||
while (data < end) {
|
||||
hash ^= static_cast<uint64_t>(*data);
|
||||
hash *= FNV_PRIME;
|
||||
data++;
|
||||
}
|
||||
|
||||
return hash;
|
||||
hash = hash * FNV_PRIME;
|
||||
hash = hash ^ byte;
|
||||
}
|
||||
|
||||
#endif // EAGLE0_BYTEHASHER_HPP
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//
|
||||
// Created by Dan Crosby on 12/25/20.
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_CONTAINERUTILS_HPP
|
||||
#define EAGLE0_CONTAINERUTILS_HPP
|
||||
|
||||
#include <algorithm>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
|
||||
namespace common {
|
||||
|
||||
using std::allocator;
|
||||
using std::back_inserter;
|
||||
using std::begin;
|
||||
using std::copy_if;
|
||||
using std::count_if;
|
||||
using std::end;
|
||||
using std::find;
|
||||
using std::find_if;
|
||||
using std::function;
|
||||
using std::optional;
|
||||
using std::remove_if;
|
||||
using std::vector;
|
||||
|
||||
template<class T, class Container>
|
||||
auto Contains(const Container& container, const T& elt) -> bool {
|
||||
return find(begin(container), end(container), elt) != end(container);
|
||||
}
|
||||
|
||||
template<class Container, class Func>
|
||||
auto CountIf(const Container& container, Func fn) -> size_t {
|
||||
Container result{};
|
||||
return count_if(begin(container), end(container), fn);
|
||||
}
|
||||
|
||||
template<class Container, class Func>
|
||||
void FilterInPlace(Container& container, Func fn) {
|
||||
container.erase(
|
||||
remove_if(begin(container), end(container), [fn](const auto& elt) { return !fn(elt); }),
|
||||
end(container));
|
||||
}
|
||||
|
||||
template<class Container, class Func>
|
||||
auto Filtered(const Container& container, Func fn) -> Container {
|
||||
Container result{};
|
||||
copy_if(begin(container), end(container), back_inserter(result), fn);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
template<class Container, class Func>
|
||||
auto FilteredToVector(const Container& container, Func fn) -> decltype(auto) {
|
||||
typedef typename Container::value_type value_type;
|
||||
vector<value_type> result{};
|
||||
copy_if(begin(container), end(container), back_inserter(result), fn);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
template<typename Container, typename Func>
|
||||
auto FindIf(const Container& container, Func fn) -> optional<typename Container::value_type> {
|
||||
const auto& t = find_if(begin(container), end(container), fn);
|
||||
|
||||
if (t == end(container)) {
|
||||
return {};
|
||||
} else {
|
||||
return optional<typename Container::value_type>(*t);
|
||||
}
|
||||
}
|
||||
|
||||
template<typename Container, typename Func>
|
||||
auto ContainsWhere(const Container& container, Func fn) -> bool {
|
||||
return find_if(begin(container), end(container), fn) != end(container);
|
||||
}
|
||||
|
||||
template<
|
||||
template<typename, typename>
|
||||
class TwoTypeContainer,
|
||||
typename T,
|
||||
typename Allocator = allocator<T>,
|
||||
typename Func>
|
||||
auto Map(const TwoTypeContainer<T, Allocator>& input, Func fn) -> decltype(auto) {
|
||||
typedef typename decltype(function(fn))::result_type result_type;
|
||||
|
||||
TwoTypeContainer<result_type, allocator<result_type>> result{};
|
||||
result.reserve(input.size());
|
||||
|
||||
transform(begin(input), end(input), back_inserter(result), fn);
|
||||
return result;
|
||||
}
|
||||
|
||||
template<template<typename> class OneTypeContainer, typename T, typename Func>
|
||||
auto Map(const OneTypeContainer<T>& input, Func fn) -> decltype(auto) {
|
||||
typedef typename decltype(function(fn))::result_type result_type;
|
||||
|
||||
OneTypeContainer<result_type> result{};
|
||||
result.reserve(input.size());
|
||||
|
||||
transform(begin(input), end(input), back_inserter(result), fn);
|
||||
return result;
|
||||
}
|
||||
|
||||
template<typename Container, typename Func>
|
||||
auto MapToVector(const Container& input, Func fn) -> decltype(auto) {
|
||||
typedef typename decltype(function(fn))::result_type result_type;
|
||||
|
||||
vector<result_type> result{};
|
||||
|
||||
transform(begin(input), end(input), back_inserter(result), fn);
|
||||
return result;
|
||||
}
|
||||
|
||||
template<
|
||||
template<typename, typename>
|
||||
class TwoTypeContainer,
|
||||
typename T,
|
||||
typename Allocator = allocator<T>,
|
||||
typename Func>
|
||||
auto FlatMap(const TwoTypeContainer<T, Allocator>& input, Func fn) -> decltype(auto) {
|
||||
typedef typename decltype(function(fn))::result_type::value_type result_value_type;
|
||||
|
||||
TwoTypeContainer<result_value_type, allocator<result_value_type>> result{};
|
||||
|
||||
for (const auto& elt : input) {
|
||||
const auto& outContainer = fn(elt);
|
||||
for (const auto& outElt : outContainer) { result.push_back(outElt); }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
template<template<typename> class OneTypeContainer, typename T, typename Func>
|
||||
auto FlatMap(const OneTypeContainer<T>& input, Func fn) -> decltype(auto) {
|
||||
typedef typename decltype(function(fn))::result_type::value_type result_value_type;
|
||||
|
||||
OneTypeContainer<result_value_type> result{};
|
||||
|
||||
for (const auto& elt : input) {
|
||||
const auto& outContainer = fn(elt);
|
||||
for (const auto& outElt : outContainer) { result.push_back(outElt); }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
template<typename Container, typename Func>
|
||||
auto FlatMapToVector(const Container& input, Func fn) -> decltype(auto) {
|
||||
typedef typename decltype(function(fn))::result_type::value_type value_type;
|
||||
|
||||
vector<value_type> result{};
|
||||
|
||||
for (const auto& elt : input) {
|
||||
const auto& outContainer = fn(elt);
|
||||
for (const auto& outElt : outContainer) { result.push_back(outElt); }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
template<typename Container>
|
||||
auto ToVector(const Container& input) -> decltype(auto) {
|
||||
typedef typename Container::value_type value_type;
|
||||
return vector<value_type>(begin(input), end(input));
|
||||
}
|
||||
|
||||
template<typename C1, typename C2>
|
||||
auto Append(C1& recipient, const C2& newItems) -> C1& {
|
||||
recipient.insert(end(recipient), begin(newItems), end(newItems));
|
||||
return recipient;
|
||||
}
|
||||
|
||||
} // namespace common
|
||||
|
||||
#endif // EAGLE0_CONTAINERUTILS_HPP
|
||||
@@ -26,18 +26,11 @@ namespace fs = std::filesystem;
|
||||
static string rLocation;
|
||||
|
||||
auto rloc(const string& execPath) -> string {
|
||||
// First check for environment variable override for Docker deployment
|
||||
const char* resourcesPath = getenv("SHARDOK_RESOURCES_PATH");
|
||||
if (resourcesPath != nullptr) {
|
||||
return ""; // Return empty so StaticShardokFilesDirectory uses env var directly
|
||||
}
|
||||
|
||||
// Fall back to Bazel runfiles for development
|
||||
string error;
|
||||
const std::unique_ptr<Runfiles> runfiles(Runfiles::Create(execPath, &error));
|
||||
|
||||
if (runfiles == nullptr) {
|
||||
fprintf(stderr, "Error! %s\n", error.c_str());
|
||||
printf("Error! %s\n", error.c_str());
|
||||
abort();
|
||||
// error handling
|
||||
}
|
||||
@@ -65,22 +58,18 @@ auto FilesystemUtils::FileExistsAtPath(const string& path) -> bool { return fs::
|
||||
auto FilesystemUtils::StaticEagle0FilesDirectory() -> string { return "/usr/local/share/eagle0/"; }
|
||||
|
||||
auto FilesystemUtils::StaticShardokFilesDirectory() -> string {
|
||||
const char* resourcesPath = getenv("SHARDOK_RESOURCES_PATH");
|
||||
if (resourcesPath != nullptr) { return string(resourcesPath) + "/"; }
|
||||
return rLocation + "/src/main/resources/net/eagle0/shardok/";
|
||||
}
|
||||
|
||||
auto FilesystemUtils::MapFilesDirectory() -> string {
|
||||
const char* mapsPath = getenv("SHARDOK_MAPS_PATH");
|
||||
if (mapsPath != nullptr) { return string(mapsPath) + "/"; }
|
||||
return StaticShardokFilesDirectory() + "maps/";
|
||||
}
|
||||
|
||||
void FilesystemUtils::MakeDirectoryIfNecessary(const string& directoryPath) {
|
||||
if (fs::create_directories(directoryPath))
|
||||
fprintf(stderr, "Directory %s created\n", directoryPath.c_str());
|
||||
printf("Directory %s created\n", directoryPath.c_str());
|
||||
else
|
||||
fprintf(stderr, "No new directory created for %s\n", directoryPath.c_str());
|
||||
printf("No new directory created for %s\n", directoryPath.c_str());
|
||||
}
|
||||
|
||||
auto FilesystemUtils::SaveFilesDirectory() -> string {
|
||||
@@ -140,11 +129,11 @@ auto FilesystemUtils::AtomicallySaveToPath(const string& path, const byte_vector
|
||||
if (ostr.good()) {
|
||||
const int err = rename(tempPath.c_str(), path.c_str());
|
||||
if (err == -1) {
|
||||
fprintf(stderr, "Failed to move file to %s! Errno %d\n", path.c_str(), errno);
|
||||
printf("Failed to move file to %s! Errno %d\n", path.c_str(), errno);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
fprintf(stderr, "Failed writing to %s!\n", tempPath.c_str());
|
||||
printf("Failed writing to %s!\n", tempPath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -156,7 +145,7 @@ auto FilesystemUtils::LoadFromPath(const string& path) -> byte_vector {
|
||||
const std::streamsize size = inputFileStream.tellg();
|
||||
inputFileStream.seekg(0, std::ios::beg);
|
||||
|
||||
auto bv = byte_vector(static_cast<size_t>(size));
|
||||
auto bv = byte_vector(size);
|
||||
inputFileStream.read((char*)bv.data(), size);
|
||||
|
||||
return bv;
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <bit>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
|
||||
#define ITERABLE_BITSET_INDEX_CHECKS false
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
#include "MapUtils.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <stdexcept>
|
||||
|
||||
static inline std::string StringForKey(
|
||||
const std::unordered_map<std::string, std::string>& map,
|
||||
|
||||
@@ -84,9 +84,7 @@ auto RandomGenerator::ChanceOpenEndedPercentileAtOrAbove(const double value) ->
|
||||
|
||||
auto StdLibraryGenerator::DoubleZeroToOne() -> double { return unifDouble(engine); }
|
||||
|
||||
StdLibraryGenerator::StdLibraryGenerator() : RandomGenerator() {
|
||||
engine.seed(static_cast<std::mt19937_64::result_type>(std::time(nullptr)));
|
||||
}
|
||||
StdLibraryGenerator::StdLibraryGenerator() : RandomGenerator() { engine.seed(std::time(nullptr)); }
|
||||
|
||||
auto StdLibraryGenerator::IntBetween(const int min, const int max) -> int {
|
||||
std::uniform_int_distribution<int> unifInt(min, max - 1);
|
||||
|
||||
@@ -14,14 +14,6 @@
|
||||
|
||||
#include "src/main/cpp/net/eagle0/common/RandomGenerator.hpp"
|
||||
|
||||
// A deterministic random generator that returns values from a fixed sequence.
|
||||
// Used for testing and MCTS simulation where we want specific, predictable outcomes.
|
||||
//
|
||||
// Values in the sequence are treated as [0, 1] probabilities that are returned
|
||||
// by DoubleZeroToOne(). The normal percentile methods (including open-ended
|
||||
// variants) work as usual, so callers must provide appropriate sequences.
|
||||
// For example, to get an open-ended low result of -50, provide [0.02, 0.52]
|
||||
// which produces: initial=2 (triggers open-ended), accumulated=52, final=2-52=-50
|
||||
class SequenceRandomGenerator : public ::RandomGenerator {
|
||||
private:
|
||||
const std::vector<double> sequence;
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
//
|
||||
// ThreadPool.cpp - Implementation of priority-based thread pool
|
||||
//
|
||||
|
||||
#include "ThreadPool.hpp"
|
||||
|
||||
namespace eagle0 {
|
||||
namespace common {
|
||||
|
||||
// Implementation is header-only to support templates
|
||||
// This file exists for potential future non-template implementations
|
||||
|
||||
} // namespace common
|
||||
} // namespace eagle0
|
||||
@@ -1,200 +0,0 @@
|
||||
//
|
||||
// ThreadPool.hpp - Priority-based thread pool with deadline support
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_THREADPOOL_HPP
|
||||
#define EAGLE0_THREADPOOL_HPP
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <functional>
|
||||
#include <future>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <queue>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace eagle0::common {
|
||||
|
||||
enum class TaskStatus { SUCCESS = 0, DEADLINE_EXCEEDED = 1, CANCELLED = 2 };
|
||||
|
||||
template<typename T>
|
||||
struct TaskResult {
|
||||
T value;
|
||||
TaskStatus status;
|
||||
|
||||
TaskResult() : value{}, status(TaskStatus::SUCCESS) {}
|
||||
TaskResult(T val) : value(std::move(val)), status(TaskStatus::SUCCESS) {}
|
||||
TaskResult(T val, TaskStatus stat) : value(std::move(val)), status(stat) {}
|
||||
|
||||
// NO implicit conversion - this was causing infinite recursion
|
||||
// Use .value or .get() instead
|
||||
T get() const { return value; }
|
||||
|
||||
bool succeeded() const { return status == TaskStatus::SUCCESS; }
|
||||
bool deadlineExceeded() const { return status == TaskStatus::DEADLINE_EXCEEDED; }
|
||||
};
|
||||
|
||||
class ThreadPool {
|
||||
public:
|
||||
using Clock = std::chrono::steady_clock;
|
||||
using TimePoint = Clock::time_point;
|
||||
|
||||
private:
|
||||
struct Task {
|
||||
std::function<void()> function;
|
||||
int priority;
|
||||
TimePoint deadline;
|
||||
bool has_deadline;
|
||||
|
||||
Task(std::function<void()> f, int p, TimePoint d, bool has_d)
|
||||
: function(std::move(f)),
|
||||
priority(p),
|
||||
deadline(d),
|
||||
has_deadline(has_d) {}
|
||||
|
||||
// Higher priority values and earlier deadlines have higher priority
|
||||
bool operator<(const Task& other) const {
|
||||
if (priority != other.priority) {
|
||||
return priority < other.priority; // Lower priority values have lower priority in
|
||||
// priority_queue
|
||||
}
|
||||
if (has_deadline && other.has_deadline) {
|
||||
return deadline > other.deadline; // Later deadlines have lower priority
|
||||
}
|
||||
if (has_deadline && !other.has_deadline) {
|
||||
return false; // Tasks with deadlines have higher priority
|
||||
}
|
||||
if (!has_deadline && other.has_deadline) {
|
||||
return true; // Tasks without deadlines have lower priority
|
||||
}
|
||||
return false; // Equal priority, no preference
|
||||
}
|
||||
};
|
||||
|
||||
std::vector<std::thread> workers;
|
||||
std::priority_queue<Task> tasks;
|
||||
std::mutex queue_mutex;
|
||||
std::condition_variable condition;
|
||||
std::atomic<bool> stop{false};
|
||||
|
||||
public:
|
||||
explicit ThreadPool(size_t num_threads = std::thread::hardware_concurrency()) {
|
||||
for (size_t i = 0; i < num_threads; ++i) {
|
||||
workers.emplace_back([this] {
|
||||
while (true) {
|
||||
Task task{nullptr, 0, TimePoint{}, false};
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(queue_mutex);
|
||||
condition.wait(lock, [this] { return stop.load() || !tasks.empty(); });
|
||||
|
||||
if (stop.load() && tasks.empty()) { return; }
|
||||
|
||||
if (!tasks.empty()) {
|
||||
task = std::move(const_cast<Task&>(tasks.top()));
|
||||
tasks.pop();
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the task (deadline checking is now handled inside the task)
|
||||
if (task.function) { task.function(); }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue a task with priority only
|
||||
template<class F, class... Args>
|
||||
auto enqueue(F&& f, Args&&... args, int priority = 0)
|
||||
-> std::future<TaskResult<std::invoke_result_t<F, Args...>>> {
|
||||
using return_type = std::invoke_result_t<F, Args...>;
|
||||
using result_type = TaskResult<return_type>;
|
||||
|
||||
auto actualTask = std::bind(std::forward<F>(f), std::forward<Args>(args)...);
|
||||
|
||||
auto task = std::make_shared<std::packaged_task<result_type()>>(
|
||||
[actualTask = std::move(actualTask)]() mutable -> result_type {
|
||||
return result_type(actualTask());
|
||||
});
|
||||
|
||||
std::future<result_type> result = task->get_future();
|
||||
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(queue_mutex);
|
||||
if (stop.load()) { throw std::runtime_error("enqueue on stopped ThreadPool"); }
|
||||
tasks.emplace([task]() { (*task)(); }, priority, TimePoint{}, false);
|
||||
}
|
||||
|
||||
condition.notify_one();
|
||||
return result;
|
||||
}
|
||||
|
||||
// Enqueue a task with priority and deadline
|
||||
template<class F, class... Args>
|
||||
auto enqueue_with_deadline(F&& f, Args&&... args, int priority, TimePoint deadline)
|
||||
-> std::future<TaskResult<std::invoke_result_t<F, Args...>>> {
|
||||
using return_type = std::invoke_result_t<F, Args...>;
|
||||
using result_type = TaskResult<return_type>;
|
||||
|
||||
auto actualTask = std::bind(std::forward<F>(f), std::forward<Args>(args)...);
|
||||
|
||||
auto task = std::make_shared<std::packaged_task<result_type()>>(
|
||||
[actualTask = std::move(actualTask), deadline]() mutable -> result_type {
|
||||
if (Clock::now() > deadline) {
|
||||
return result_type(return_type{}, TaskStatus::DEADLINE_EXCEEDED);
|
||||
}
|
||||
return result_type(actualTask());
|
||||
});
|
||||
|
||||
std::future<result_type> result = task->get_future();
|
||||
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(queue_mutex);
|
||||
if (stop.load()) { throw std::runtime_error("enqueue on stopped ThreadPool"); }
|
||||
tasks.emplace([task]() { (*task)(); }, priority, deadline, true);
|
||||
}
|
||||
|
||||
condition.notify_one();
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get current queue size (approximate, for monitoring)
|
||||
size_t queue_size() const {
|
||||
std::unique_lock<std::mutex> lock(const_cast<std::mutex&>(queue_mutex));
|
||||
return tasks.size();
|
||||
}
|
||||
|
||||
// Get detailed queue information for debugging
|
||||
void debug_queue_state() const {
|
||||
std::unique_lock<std::mutex> lock(const_cast<std::mutex&>(queue_mutex));
|
||||
printf("ThreadPool: Queue size: %zu\n", tasks.size());
|
||||
if (!tasks.empty()) {
|
||||
// Create a copy to inspect priorities without modifying queue
|
||||
auto queue_copy = tasks;
|
||||
std::vector<int> priorities;
|
||||
while (!queue_copy.empty()) {
|
||||
priorities.push_back(queue_copy.top().priority);
|
||||
queue_copy.pop();
|
||||
}
|
||||
printf("ThreadPool: Priorities in queue: ");
|
||||
for (int p : priorities) { printf("%d ", p); }
|
||||
printf("\n");
|
||||
}
|
||||
}
|
||||
|
||||
~ThreadPool() {
|
||||
stop.store(true);
|
||||
condition.notify_all();
|
||||
for (std::thread& worker : workers) {
|
||||
if (worker.joinable()) { worker.join(); }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace eagle0::common
|
||||
|
||||
#endif // EAGLE0_THREADPOOL_HPP
|
||||
@@ -8,8 +8,6 @@ namespace shardok {
|
||||
|
||||
using Coords = net::eagle0::shardok::storage::fb::Coords;
|
||||
|
||||
constexpr double kDefaultMorale = 50.0;
|
||||
|
||||
auto ConvertBattalion(const net::eagle0::common::CommonBattalion &battalion) -> Battalion {
|
||||
Battalion shardokBattalion{};
|
||||
|
||||
@@ -17,9 +15,9 @@ auto ConvertBattalion(const net::eagle0::common::CommonBattalion &battalion) ->
|
||||
shardokBattalion.mutate_size(battalion.size());
|
||||
shardokBattalion.mutate_type(
|
||||
static_cast<net::eagle0::shardok::storage::fb::BattalionTypeId>(battalion.type()));
|
||||
shardokBattalion.mutate_morale(kDefaultMorale);
|
||||
shardokBattalion.mutate_armament(static_cast<float>(battalion.armament()));
|
||||
shardokBattalion.mutate_training(static_cast<float>(battalion.training()));
|
||||
shardokBattalion.mutate_morale(battalion.morale());
|
||||
shardokBattalion.mutate_armament(battalion.armament());
|
||||
shardokBattalion.mutate_training(battalion.training());
|
||||
|
||||
return shardokBattalion;
|
||||
}
|
||||
@@ -39,28 +37,28 @@ auto ConvertHero(const net::eagle0::common::CommonHero &hero) -> Hero {
|
||||
shardokHero.mutable_control_info().mutate_controlled_unit_id(-1);
|
||||
shardokHero.mutable_control_info().mutate_controlled_this_round(false);
|
||||
|
||||
shardokHero.mutate_strength(static_cast<int8_t>(hero.strength()));
|
||||
shardokHero.mutate_strength_xp(static_cast<int16_t>(hero.strength_xp()));
|
||||
shardokHero.mutate_strength(hero.strength());
|
||||
shardokHero.mutate_strength_xp(hero.strength_xp());
|
||||
|
||||
shardokHero.mutate_agility(static_cast<int8_t>(hero.agility()));
|
||||
shardokHero.mutate_agility_xp(static_cast<int16_t>(hero.agility_xp()));
|
||||
shardokHero.mutate_agility(hero.agility());
|
||||
shardokHero.mutate_agility_xp(hero.agility_xp());
|
||||
|
||||
shardokHero.mutate_constitution(static_cast<int8_t>(hero.constitution()));
|
||||
shardokHero.mutate_constitution_xp(static_cast<int16_t>(hero.constitution_xp()));
|
||||
shardokHero.mutate_constitution(hero.constitution());
|
||||
shardokHero.mutate_constitution_xp(hero.constitution_xp());
|
||||
|
||||
shardokHero.mutate_charisma(static_cast<int8_t>(hero.charisma()));
|
||||
shardokHero.mutate_charisma_xp(static_cast<int16_t>(hero.charisma_xp()));
|
||||
shardokHero.mutate_charisma(hero.charisma());
|
||||
shardokHero.mutate_charisma_xp(hero.charisma_xp());
|
||||
|
||||
shardokHero.mutate_wisdom(static_cast<int8_t>(hero.wisdom()));
|
||||
shardokHero.mutate_wisdom_xp(static_cast<int16_t>(hero.wisdom_xp()));
|
||||
shardokHero.mutate_wisdom(hero.wisdom());
|
||||
shardokHero.mutate_wisdom_xp(hero.wisdom_xp());
|
||||
|
||||
shardokHero.mutate_integrity(static_cast<int8_t>(hero.integrity()));
|
||||
shardokHero.mutate_ambition(static_cast<int8_t>(hero.ambition()));
|
||||
shardokHero.mutate_gregariousness(static_cast<int8_t>(hero.gregariousness()));
|
||||
shardokHero.mutate_bravery(static_cast<int8_t>(hero.bravery()));
|
||||
shardokHero.mutate_integrity(hero.integrity());
|
||||
shardokHero.mutate_ambition(hero.ambition());
|
||||
shardokHero.mutate_gregariousness(hero.gregariousness());
|
||||
shardokHero.mutate_bravery(hero.bravery());
|
||||
|
||||
shardokHero.mutate_vigor(static_cast<float>(hero.vigor()));
|
||||
shardokHero.mutate_starting_vigor(static_cast<float>(hero.vigor()));
|
||||
shardokHero.mutate_vigor(hero.vigor());
|
||||
shardokHero.mutate_starting_vigor(hero.vigor());
|
||||
|
||||
return shardokHero;
|
||||
}
|
||||
@@ -95,22 +93,19 @@ auto ConvertUnit(
|
||||
shardokUnit.mutate_stun_rounds_remaining(0);
|
||||
|
||||
for (const PlayerId pid : allPlayerIds) {
|
||||
shardokUnit.mutable_opponent_knowledge()->Mutate(
|
||||
static_cast<flatbuffers::uoffset_t>(pid),
|
||||
0);
|
||||
shardokUnit.mutable_opponent_knowledge()->Mutate(pid, 0);
|
||||
}
|
||||
|
||||
shardokUnit.mutate_has_moved_in_zoc(false);
|
||||
shardokUnit.mutate_targeted_unit(-1);
|
||||
shardokUnit.mutate_volleys_remaining(0);
|
||||
shardokUnit.mutate_food_remaining(static_cast<float>(unit.food()));
|
||||
shardokUnit.mutate_food_remaining(unit.food());
|
||||
shardokUnit.mutate_can_flee(unit.can_flee());
|
||||
shardokUnit.mutate_can_archery(unit.can_archery());
|
||||
shardokUnit.mutate_can_start_fire(unit.can_start_fire());
|
||||
|
||||
if (unit.has_starting_position_index()) {
|
||||
shardokUnit.mutate_starting_position_index(
|
||||
static_cast<int8_t>(unit.starting_position_index().value()));
|
||||
shardokUnit.mutate_starting_position_index(unit.starting_position_index().value());
|
||||
} else {
|
||||
shardokUnit.mutate_starting_position_index(-1);
|
||||
}
|
||||
|
||||
@@ -9,10 +9,7 @@
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
|
||||
#include "src/main/flatbuffer/net/eagle0/shardok/storage/unit.hpp"
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wdeprecated-redundant-constexpr-static-def"
|
||||
#include "src/main/protobuf/net/eagle0/common/common_unit.pb.h"
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
namespace shardok {
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
#ifndef byte_vector_h
|
||||
#define byte_vector_h
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
# MCTS (Monte Carlo Tree Search) Framework
|
||||
|
||||
This directory contains a game-agnostic Monte Carlo Tree Search implementation that can be used with any turn-based game. The framework separates the MCTS algorithm from game-specific logic through abstract interfaces.
|
||||
|
||||
## Core Abstract Classes
|
||||
|
||||
### `MCTSAction` (abstract/MCTSAction.hpp)
|
||||
Abstract interface for representing game actions/moves.
|
||||
|
||||
**Key Methods:**
|
||||
- `getIndex()` - Returns the action's unique identifier
|
||||
- `getDescription()` - Human-readable description for debugging/logging
|
||||
- `clone()` - Creates a deep copy of the action
|
||||
- `equals()` - Compares actions for equality
|
||||
|
||||
### `MCTSGameState` (abstract/MCTSGameState.hpp)
|
||||
Abstract interface for representing game states.
|
||||
|
||||
**Key Methods:**
|
||||
- `hash()` - Returns a hash for transposition table lookups
|
||||
- `score(playerId)` - Evaluates the state's value for a given player
|
||||
- `currentPlayerId()` - Returns whose turn it is
|
||||
- `isTerminal()` - Checks if the game has ended
|
||||
- `getWinner()` - Returns the winning player (if terminal)
|
||||
- `clone()` - Creates a deep copy of the state
|
||||
- `equals()` - Compares states for equality
|
||||
|
||||
### `MCTSGameEngine` (abstract/MCTSGameEngine.hpp)
|
||||
Abstract interface for game rule enforcement and state transitions. Many methods have efficient default implementations.
|
||||
|
||||
**Must Override (Pure Virtual):**
|
||||
- `applyAction(state, action)` - Applies an action to create a new state
|
||||
- `getLegalActions(state)` - Returns all valid moves from a state
|
||||
- `isTerminal(state)` - Checks if a state is game-ending
|
||||
- `evaluateState(state, playerId)` - Scores a state for a player
|
||||
|
||||
**Optional Overrides (Have Default Implementations):**
|
||||
- `applyActionMutable(state, action)` - Apply action in-place for efficiency (default: calls applyAction)
|
||||
- `filterActions(actions, state)` - Applies heuristic filtering (default: no filtering)
|
||||
- `simulateRandomPlayout(state, playerId, maxDepth, policy)` - Runs simulation (default: efficient mutable implementation)
|
||||
- `getActionScore(state, action, playerId)` - Scores an action (default: apply and evaluate)
|
||||
- `shouldStopSearch(state, iterations, startTime)` - Early termination (default: no early stop)
|
||||
|
||||
**Performance Features:**
|
||||
- The default `simulateRandomPlayout` clones the state once and mutates it throughout simulation for efficiency
|
||||
- Games can override `applyActionMutable` to provide even more efficient in-place updates
|
||||
- Games can override `simulateRandomPlayout` for custom optimizations (e.g., using internal engine state)
|
||||
|
||||
## MCTS Algorithm Implementation
|
||||
|
||||
### `AbstractMCTSAI` (abstract/AbstractMCTSAI.hpp)
|
||||
The main MCTS algorithm implementation that works with any game implementing the abstract interfaces.
|
||||
|
||||
**Key Features:**
|
||||
- **Selection**: Uses UCB1 (Upper Confidence Bound) for node selection
|
||||
- **Expansion**: Adds new nodes to the search tree
|
||||
- **Simulation**: Runs random playouts to estimate node values
|
||||
- **Backpropagation**: Updates node statistics with simulation results
|
||||
- **Multithreading**: Supports parallel MCTS with configurable thread count
|
||||
- **Path Compression**: Optimizes move sequences for better performance
|
||||
|
||||
**Configuration Options:**
|
||||
- `explorationConstant` - UCB1 exploration parameter (default: √2)
|
||||
- `maxSimulationDepth` - Maximum depth for random playouts
|
||||
- `maxTreeDepth` - Maximum tree depth to prevent stack overflow
|
||||
- `useMultithreading` - Enable parallel search
|
||||
- `numThreads` - Number of worker threads
|
||||
- `simulationPolicy` - Strategy for action selection during simulation
|
||||
|
||||
### `MCTSNode` (abstract/MCTSNode.hpp)
|
||||
Represents nodes in the MCTS search tree.
|
||||
|
||||
**Core Data:**
|
||||
- `action` - The action that led to this node
|
||||
- `actionIndex` - Index in the original actions array
|
||||
- `gameState` - The game state at this node
|
||||
- `visitCount` - Number of times this node was visited
|
||||
- `totalReward` - Sum of simulation rewards
|
||||
- `averageReward` - Average reward (totalReward / visitCount)
|
||||
- `children` - Child nodes in the search tree
|
||||
- `parent` - Parent node reference
|
||||
|
||||
**Key Methods:**
|
||||
- `CanExpand()` - Checks if node has untried actions
|
||||
- `GetBestChild(explorationConstant)` - UCB1-based child selection
|
||||
- `GetBestFinalChild()` - Most-visited child (for final move selection)
|
||||
- `CalculateUCB1(explorationConstant)` - Computes UCB1 value
|
||||
|
||||
## Simulation Policies
|
||||
|
||||
The framework supports multiple strategies for action selection during random playouts:
|
||||
|
||||
- **RANDOM** - Uniform random selection
|
||||
- **FILTERED_RANDOM** - Random selection from filtered action set
|
||||
- **BEST_IMMEDIATE** - Always choose the highest-scoring immediate action
|
||||
- **WEIGHTED_BEST_IMMEDIATE** - Weighted random selection based on action scores
|
||||
|
||||
## Type Definitions
|
||||
|
||||
### `MCTSTypes` (abstract/MCTSTypes.hpp)
|
||||
- `MCTSPlayerId` - Player identifier type (int)
|
||||
- `MCTSSimulationPolicy` - Enumeration of simulation strategies
|
||||
- `MCTSConfig` - Configuration structure for MCTS parameters
|
||||
|
||||
## Usage Pattern
|
||||
|
||||
To use this framework with your game:
|
||||
|
||||
1. **Implement the abstract interfaces** for your game:
|
||||
```cpp
|
||||
class MyGameAction : public MCTSAction { /* ... */ };
|
||||
class MyGameState : public MCTSGameState { /* ... */ };
|
||||
class MyGameEngine : public MCTSGameEngine { /* ... */ };
|
||||
```
|
||||
|
||||
2. **Create and configure the AI**:
|
||||
```cpp
|
||||
MCTSConfig config;
|
||||
config.explorationConstant = 1.414;
|
||||
config.maxSimulationDepth = 100;
|
||||
AbstractMCTSAI ai(playerId, config);
|
||||
```
|
||||
|
||||
3. **Run the search**:
|
||||
```cpp
|
||||
auto actions = engine.getLegalActions(currentState);
|
||||
auto result = ai.Search(engine, currentState, actions, timeLimit);
|
||||
auto bestAction = actions[result.bestActionIndex];
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
The framework includes comprehensive tests using a Tic-Tac-Toe implementation:
|
||||
- `MockTicTacToe.hpp` - Example implementation of all abstract interfaces
|
||||
- `AbstractMCTSAI_test.cpp` - Unit tests for the core algorithm
|
||||
- `MCTSIntegration_test.cpp` - Integration tests with complete games
|
||||
- `MCTSNode_test.cpp` - Tests for the node data structure
|
||||
|
||||
This demonstrates how to implement the interfaces and validates that the MCTS algorithm works correctly with any turn-based game.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,106 +0,0 @@
|
||||
//
|
||||
// Abstract MCTS AI implementation - game agnostic
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_ABSTRACT_MCTSAI_HPP
|
||||
#define EAGLE0_ABSTRACT_MCTSAI_HPP
|
||||
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "MCTSAction.hpp"
|
||||
#include "MCTSGameEngine.hpp"
|
||||
#include "MCTSGameState.hpp"
|
||||
#include "MCTSNode.hpp"
|
||||
#include "MCTSTypes.hpp"
|
||||
|
||||
namespace shardok {
|
||||
namespace mcts {
|
||||
|
||||
class AbstractMCTSAI {
|
||||
public:
|
||||
// Search result structure
|
||||
struct SearchResult {
|
||||
size_t bestActionIndex = 0;
|
||||
double bestScore = 0.0;
|
||||
int searchDepth = 0;
|
||||
int nodesEvaluated = 0;
|
||||
std::chrono::milliseconds searchTime{0};
|
||||
bool foundWinningMove = false;
|
||||
};
|
||||
|
||||
explicit AbstractMCTSAI(MCTSPlayerId playerId, MCTSConfig config = MCTSConfig{});
|
||||
|
||||
// Main search interface
|
||||
[[nodiscard]] auto Search(
|
||||
const MCTSGameEngine& engine,
|
||||
const MCTSGameState& initialState,
|
||||
std::chrono::milliseconds timeLimit) const -> SearchResult;
|
||||
|
||||
// Configuration
|
||||
[[nodiscard]] auto GetConfig() const -> const MCTSConfig& { return config_; }
|
||||
void SetConfig(const MCTSConfig& newConfig) { config_ = newConfig; }
|
||||
|
||||
[[nodiscard]] auto FindNodeAtDepthWithHash(
|
||||
const MCTSNode* root,
|
||||
int maxDepth,
|
||||
uint64_t targetHash) -> const MCTSNode*;
|
||||
|
||||
private:
|
||||
MCTSPlayerId playerId_;
|
||||
MCTSConfig config_;
|
||||
|
||||
// Transposition table: maps state hash -> minimum depth at which state was reached
|
||||
// Used to detect and penalize longer paths to the same game state
|
||||
// Cleared at the start of each Search() call
|
||||
mutable std::unordered_map<uint64_t, int> transpositionTable_;
|
||||
|
||||
// Core MCTS algorithm
|
||||
[[nodiscard]] auto BuildMCTSTree(
|
||||
const MCTSGameEngine& engine,
|
||||
const MCTSGameState& initialState,
|
||||
std::chrono::steady_clock::time_point deadline) const -> std::unique_ptr<MCTSNode>;
|
||||
|
||||
// MCTS phases
|
||||
[[nodiscard]] auto MCTSSelection(MCTSNode* root) const -> MCTSNode*;
|
||||
|
||||
[[nodiscard]] auto MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine) const
|
||||
-> MCTSNode*;
|
||||
|
||||
[[nodiscard]] auto MCTSSimulation(
|
||||
const MCTSGameEngine& engine,
|
||||
const MCTSGameState& state,
|
||||
MCTSPlayerId startingPlayer,
|
||||
int startingPlayerFlips = 0) const -> double;
|
||||
|
||||
auto MCTSBackpropagation(MCTSNode* node, double reward, MCTSBackpropagationPolicy policy) const
|
||||
-> void;
|
||||
|
||||
// Helper functions
|
||||
[[nodiscard]] auto SelectSimulationAction(
|
||||
const MCTSGameEngine& engine,
|
||||
const MCTSGameState& state,
|
||||
const std::vector<std::unique_ptr<MCTSAction>>& actions,
|
||||
bool isMaximizing) const -> size_t;
|
||||
|
||||
// Logging
|
||||
static auto LogSearchResults(
|
||||
const MCTSNode* rootNode,
|
||||
const MCTSNode* bestChild,
|
||||
const SearchResult& result) -> void;
|
||||
|
||||
// Debug tree dumping
|
||||
static auto DumpTreeToFile(const MCTSNode* root, const std::string& filepath) -> void;
|
||||
|
||||
private:
|
||||
static auto
|
||||
DumpNodeRecursive(const MCTSNode* node, std::ostream& out, int indentLevel, bool isLastChild)
|
||||
-> void;
|
||||
};
|
||||
|
||||
} // namespace mcts
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_ABSTRACT_MCTSAI_HPP
|
||||
@@ -1,94 +0,0 @@
|
||||
load("//tools:copts.bzl", "COPTS")
|
||||
|
||||
cc_library(
|
||||
name = "mcts_types",
|
||||
hdrs = ["MCTSTypes.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "mcts_action",
|
||||
hdrs = ["MCTSAction.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "mcts_game_state",
|
||||
hdrs = ["MCTSGameState.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":mcts_types",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "mcts_game_engine",
|
||||
srcs = ["MCTSGameEngine.cpp"],
|
||||
hdrs = ["MCTSGameEngine.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":mcts_action",
|
||||
":mcts_game_state",
|
||||
":mcts_types",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "mcts_node",
|
||||
hdrs = ["MCTSNode.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:__pkg__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":mcts_action",
|
||||
":mcts_game_state",
|
||||
":mcts_types",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "abstract_mcts_ai",
|
||||
srcs = ["AbstractMCTSAI.cpp"],
|
||||
hdrs = ["AbstractMCTSAI.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":mcts_action",
|
||||
":mcts_game_engine",
|
||||
":mcts_game_state",
|
||||
":mcts_node",
|
||||
":mcts_types",
|
||||
"//src/main/cpp/net/eagle0/common/mcts/util:tree_indent_util",
|
||||
],
|
||||
)
|
||||
|
||||
# Individual targets are exposed above - no need for a catch-all target
|
||||
# Each component should be imported explicitly by its consumers
|
||||
@@ -1,40 +0,0 @@
|
||||
//
|
||||
// Abstract action interface for MCTS
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_MCTS_ACTION_HPP
|
||||
#define EAGLE0_MCTS_ACTION_HPP
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace shardok {
|
||||
namespace mcts {
|
||||
|
||||
// Abstract interface for game actions
|
||||
class MCTSAction {
|
||||
public:
|
||||
virtual ~MCTSAction() = default;
|
||||
|
||||
// Get a unique index for this action (used for command indexing)
|
||||
[[nodiscard]] virtual size_t getIndex() const = 0;
|
||||
|
||||
// Get a human-readable description for debugging/logging
|
||||
[[nodiscard]] virtual std::string getDescription() const = 0;
|
||||
|
||||
// Create a deep copy of this action
|
||||
[[nodiscard]] virtual std::unique_ptr<MCTSAction> clone() const = 0;
|
||||
|
||||
// Check if two actions are equivalent
|
||||
[[nodiscard]] virtual bool equals(const MCTSAction& other) const = 0;
|
||||
|
||||
// Check if this action requires a chance node (binary success/failure outcome)
|
||||
// Examples: START_FIRE, RAISE_DEAD, EXTINGUISH_FIRE
|
||||
// If true, the game engine should provide outcome probabilities
|
||||
[[nodiscard]] virtual bool requiresChanceNode() const = 0;
|
||||
};
|
||||
|
||||
} // namespace mcts
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_MCTS_ACTION_HPP
|
||||
@@ -1,148 +0,0 @@
|
||||
//
|
||||
// Default implementations for MCTSGameEngine
|
||||
//
|
||||
|
||||
#include "MCTSGameEngine.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
#include <random>
|
||||
#include <vector>
|
||||
|
||||
#include "MCTSTypes.hpp" // For MCTSInternalError
|
||||
|
||||
namespace shardok {
|
||||
namespace mcts {
|
||||
|
||||
double MCTSGameEngine::simulateRandomPlayout(
|
||||
const MCTSGameState& state,
|
||||
MCTSPlayerId playerId,
|
||||
int maxDepth,
|
||||
MCTSSimulationPolicy policy) const {
|
||||
// Clone state once and mutate it throughout simulation for efficiency
|
||||
auto currentState = state.clone();
|
||||
int depth = 0;
|
||||
|
||||
// Use thread-local random generator for thread safety
|
||||
static thread_local std::mt19937 gen(std::random_device{}());
|
||||
|
||||
// Simulate until terminal or max depth
|
||||
while (!currentState->isTerminal() && depth < maxDepth) {
|
||||
auto actions = getLegalActions(*currentState, playerId, 0, 0);
|
||||
if (actions.empty()) { break; }
|
||||
|
||||
size_t selectedIndex = 0;
|
||||
|
||||
// Select action based on policy
|
||||
switch (policy) {
|
||||
case MCTSSimulationPolicy::RANDOM: {
|
||||
std::uniform_int_distribution<> dis(0, actions.size() - 1);
|
||||
selectedIndex = dis(gen);
|
||||
break;
|
||||
}
|
||||
|
||||
case MCTSSimulationPolicy::FILTERED_RANDOM: {
|
||||
auto filteredIndices = filterActions(actions, *currentState);
|
||||
if (!filteredIndices.empty()) {
|
||||
std::uniform_int_distribution<> dis(0, filteredIndices.size() - 1);
|
||||
selectedIndex = filteredIndices[dis(gen)];
|
||||
} else {
|
||||
// Fall back to random if no actions pass filter
|
||||
std::uniform_int_distribution<> dis(0, actions.size() - 1);
|
||||
selectedIndex = dis(gen);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case MCTSSimulationPolicy::BEST_IMMEDIATE: {
|
||||
double bestScore = -std::numeric_limits<double>::infinity();
|
||||
for (size_t i = 0; i < actions.size(); ++i) {
|
||||
double score = getActionScore(
|
||||
*currentState,
|
||||
*actions[i],
|
||||
currentState->currentPlayerId());
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
selectedIndex = i;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case MCTSSimulationPolicy::WEIGHTED_BEST_IMMEDIATE: {
|
||||
// Score all actions and weight by ranking
|
||||
std::vector<std::pair<size_t, double>> scores;
|
||||
scores.reserve(actions.size());
|
||||
|
||||
for (size_t i = 0; i < actions.size(); ++i) {
|
||||
double score = getActionScore(
|
||||
*currentState,
|
||||
*actions[i],
|
||||
currentState->currentPlayerId());
|
||||
scores.emplace_back(i, score);
|
||||
}
|
||||
|
||||
// Sort by score (descending)
|
||||
std::sort(scores.begin(), scores.end(), [](const auto& a, const auto& b) {
|
||||
return a.second > b.second;
|
||||
});
|
||||
|
||||
// Create weights based on ranking (1/rank)
|
||||
std::vector<double> weights;
|
||||
weights.reserve(scores.size());
|
||||
for (size_t i = 0; i < scores.size(); ++i) { weights.push_back(1.0 / (i + 1.0)); }
|
||||
|
||||
// Select based on weights
|
||||
std::discrete_distribution<> dis(weights.begin(), weights.end());
|
||||
selectedIndex = scores[dis(gen)].first;
|
||||
break;
|
||||
}
|
||||
|
||||
case MCTSSimulationPolicy::WEIGHTED_HEURISTIC: {
|
||||
// Get heuristic weights (fast O(1) per action)
|
||||
const auto weights = getActionWeights(actions, *currentState);
|
||||
|
||||
// Filter out zero-weight actions
|
||||
std::vector<size_t> validIndices;
|
||||
std::vector<double> validWeights;
|
||||
validIndices.reserve(actions.size());
|
||||
validWeights.reserve(actions.size());
|
||||
|
||||
for (size_t i = 0; i < weights.size() && i < actions.size(); ++i) {
|
||||
if (weights[i] > 0.0) {
|
||||
validIndices.push_back(i);
|
||||
validWeights.push_back(weights[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// If all actions filtered out, this is a bug in the weighting logic
|
||||
if (validWeights.empty()) {
|
||||
throw MCTSInternalError(
|
||||
"MCTS simulation (playout): All actions have zero weight in "
|
||||
"WEIGHTED_HEURISTIC policy (action count: " +
|
||||
std::to_string(actions.size()) +
|
||||
") - this indicates incorrect weighting");
|
||||
}
|
||||
|
||||
// Select based on heuristic weights
|
||||
std::discrete_distribution<> dis(validWeights.begin(), validWeights.end());
|
||||
selectedIndex = validIndices[dis(gen)];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply selected action using mutable version for efficiency
|
||||
applyActionMutable(currentState, *actions[selectedIndex]);
|
||||
if (!currentState) {
|
||||
break; // Failed to apply action
|
||||
}
|
||||
|
||||
depth++;
|
||||
}
|
||||
|
||||
// Return evaluation from original player's perspective
|
||||
return evaluateState(*currentState, playerId);
|
||||
}
|
||||
|
||||
} // namespace mcts
|
||||
} // namespace shardok
|
||||
@@ -1,161 +0,0 @@
|
||||
//
|
||||
// Abstract game engine interface for MCTS
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_MCTS_GAME_ENGINE_HPP
|
||||
#define EAGLE0_MCTS_GAME_ENGINE_HPP
|
||||
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "MCTSAction.hpp"
|
||||
#include "MCTSGameState.hpp"
|
||||
#include "MCTSTypes.hpp"
|
||||
|
||||
namespace shardok {
|
||||
namespace mcts {
|
||||
|
||||
// Information about chance outcomes (supports both binary and multi-outcome)
|
||||
struct ChanceOutcomeInfo {
|
||||
std::vector<double> probabilities; // Probability of each outcome (must sum to 1.0)
|
||||
std::vector<double> rolls; // Roll values for each outcome
|
||||
|
||||
// Factory for binary success/failure outcomes (e.g., START_FIRE)
|
||||
[[nodiscard]] static ChanceOutcomeInfo binary(double successProbability) {
|
||||
// -100: triggers open-ended low sequence, succeeds against any threshold
|
||||
// 150: triggers open-ended high sequence, fails against any threshold
|
||||
return {{successProbability, 1.0 - successProbability}, {-100.0, 150.0}};
|
||||
}
|
||||
|
||||
// Factory for multi-outcome with fixed seeds (e.g., END_TURN)
|
||||
// Uses uniformly distributed roll values to sample different random outcomes
|
||||
[[nodiscard]] static ChanceOutcomeInfo multiOutcome(int numOutcomes) {
|
||||
std::vector<double> probs(numOutcomes, 1.0 / numOutcomes);
|
||||
std::vector<double> rollValues;
|
||||
rollValues.reserve(numOutcomes);
|
||||
// Spread rolls across the percentile range: 10, 30, 50, 70, 90 for 5 outcomes
|
||||
for (int i = 0; i < numOutcomes; ++i) {
|
||||
rollValues.push_back(10.0 + (80.0 * i) / (numOutcomes - 1));
|
||||
}
|
||||
return {probs, rollValues};
|
||||
}
|
||||
|
||||
[[nodiscard]] const std::vector<double>& getRepresentativeRolls() const { return rolls; }
|
||||
|
||||
[[nodiscard]] const std::vector<double>& getProbabilities() const { return probabilities; }
|
||||
};
|
||||
|
||||
// Backward compatibility alias
|
||||
using BinaryOutcomeInfo = ChanceOutcomeInfo;
|
||||
|
||||
// Abstract interface for game engines
|
||||
class MCTSGameEngine {
|
||||
public:
|
||||
virtual ~MCTSGameEngine() = default;
|
||||
|
||||
// Apply an action to a state and return the resulting state
|
||||
// If deterministicRoll is provided (0.0-100.0), use that for any random outcomes
|
||||
[[nodiscard]] virtual std::unique_ptr<MCTSGameState> applyAction(
|
||||
const MCTSGameState& state,
|
||||
const MCTSAction& action,
|
||||
double deterministicRoll = -1.0) const = 0;
|
||||
|
||||
// Apply an action to a mutable state in-place (for efficient simulation)
|
||||
// Default: clone, apply, and move the result back
|
||||
// Override this for better performance
|
||||
virtual void applyActionMutable(std::unique_ptr<MCTSGameState>& state, const MCTSAction& action)
|
||||
const {
|
||||
state = applyAction(*state, action);
|
||||
}
|
||||
|
||||
// Get all legal actions for the current state with player flip tracking
|
||||
// Default implementation ignores flip tracking and calls base version
|
||||
[[nodiscard]] virtual std::vector<std::unique_ptr<MCTSAction>> getLegalActions(
|
||||
const MCTSGameState& state,
|
||||
MCTSPlayerId /*rootPlayerId*/,
|
||||
int /*currentPlayerFlips*/,
|
||||
int /*maxPlayerFlips*/) const = 0;
|
||||
|
||||
// Check if a state is terminal
|
||||
[[nodiscard]] virtual bool isTerminal(const MCTSGameState& state) const = 0;
|
||||
|
||||
// Evaluate a state from the perspective of a player
|
||||
[[nodiscard]] virtual double evaluateState(const MCTSGameState& state, MCTSPlayerId playerId)
|
||||
const = 0;
|
||||
|
||||
// Filter actions based on game-specific heuristics
|
||||
// Returns indices of actions to keep
|
||||
// Default: no filtering (return all indices)
|
||||
[[nodiscard]] virtual std::vector<size_t> filterActions(
|
||||
const std::vector<std::unique_ptr<MCTSAction>>& actions,
|
||||
const MCTSGameState& /*state*/) const {
|
||||
std::vector<size_t> indices;
|
||||
indices.reserve(actions.size());
|
||||
for (size_t i = 0; i < actions.size(); ++i) { indices.push_back(i); }
|
||||
return indices;
|
||||
}
|
||||
|
||||
// Get heuristic weights for actions (used by WEIGHTED_HEURISTIC simulation policy)
|
||||
// Returns weights corresponding to each action (same size as actions vector)
|
||||
// Weight of 0.0 = never select, higher = more likely to select
|
||||
// Default: uniform weights (all actions equally likely)
|
||||
[[nodiscard]] virtual std::vector<double> getActionWeights(
|
||||
const std::vector<std::unique_ptr<MCTSAction>>& actions,
|
||||
const MCTSGameState& /*state*/) const {
|
||||
// Default: uniform weights
|
||||
return std::vector<double>(actions.size(), 1.0);
|
||||
}
|
||||
|
||||
// Simulate a random playout from the given state
|
||||
// Default implementation uses policy to select actions
|
||||
[[nodiscard]] virtual double simulateRandomPlayout(
|
||||
const MCTSGameState& state,
|
||||
MCTSPlayerId playerId,
|
||||
int maxDepth,
|
||||
MCTSSimulationPolicy policy) const;
|
||||
|
||||
// Get the immediate score of applying an action
|
||||
// Default: apply the action and evaluate the resulting state
|
||||
[[nodiscard]] virtual double getActionScore(
|
||||
const MCTSGameState& state,
|
||||
const MCTSAction& action,
|
||||
MCTSPlayerId playerId) const {
|
||||
auto newState = applyAction(state, action);
|
||||
if (!newState) { return 0.0; }
|
||||
return evaluateState(*newState, playerId);
|
||||
}
|
||||
|
||||
// Check if we should stop searching (e.g., time limit, found winning move)
|
||||
[[nodiscard]] virtual bool shouldStopSearch(
|
||||
const MCTSGameState& /*state*/,
|
||||
int /*iterations*/,
|
||||
std::chrono::steady_clock::time_point /*startTime*/) const {
|
||||
// Default: no early stopping
|
||||
return false;
|
||||
}
|
||||
|
||||
// Map a filtered action index back to the original unfiltered index
|
||||
// This is needed when getLegalActions() applies filtering - the returned actions
|
||||
// may be a subset of all available actions, and this maps back to the original index.
|
||||
// Default implementation: no filtering, so filtered index = original index
|
||||
[[nodiscard]] virtual size_t mapFilteredIndexToOriginal(
|
||||
size_t filteredIndex,
|
||||
const MCTSGameState& state) const {
|
||||
// Default: no filtering, index stays the same
|
||||
(void)state; // Suppress unused parameter warning
|
||||
return filteredIndex;
|
||||
}
|
||||
|
||||
// Get binary outcome information for an action that requires a chance node
|
||||
// Only called for actions where action.requiresChanceNode() returns true
|
||||
// Returns success probability for binary success/failure actions
|
||||
[[nodiscard]] virtual BinaryOutcomeInfo getBinaryOutcomeInfo(
|
||||
const MCTSGameState& state,
|
||||
const MCTSAction& action) const = 0;
|
||||
};
|
||||
|
||||
} // namespace mcts
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_MCTS_GAME_ENGINE_HPP
|
||||
@@ -1,50 +0,0 @@
|
||||
//
|
||||
// Abstract game state interface for MCTS
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_MCTS_GAME_STATE_HPP
|
||||
#define EAGLE0_MCTS_GAME_STATE_HPP
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "MCTSTypes.hpp"
|
||||
|
||||
namespace shardok {
|
||||
namespace mcts {
|
||||
|
||||
// Abstract interface for game states
|
||||
class MCTSGameState {
|
||||
public:
|
||||
virtual ~MCTSGameState() = default;
|
||||
|
||||
// Compute hash for transposition table
|
||||
[[nodiscard]] virtual uint64_t hash() const = 0;
|
||||
|
||||
// Evaluate the state from the perspective of the given player
|
||||
[[nodiscard]] virtual double score(MCTSPlayerId playerId) const = 0;
|
||||
|
||||
// Get the player whose turn it is
|
||||
[[nodiscard]] virtual MCTSPlayerId currentPlayerId() const = 0;
|
||||
|
||||
// Check if the game has ended
|
||||
[[nodiscard]] virtual bool isTerminal() const = 0;
|
||||
|
||||
// Create a deep copy of the state
|
||||
[[nodiscard]] virtual std::unique_ptr<MCTSGameState> clone() const = 0;
|
||||
|
||||
// Check if two states are equivalent
|
||||
[[nodiscard]] virtual bool equals(const MCTSGameState& other) const = 0;
|
||||
|
||||
// Get winner if terminal, or -1 if not terminal or draw
|
||||
[[nodiscard]] virtual MCTSPlayerId getWinner() const = 0;
|
||||
|
||||
// Optional: Get a string representation for debugging
|
||||
[[nodiscard]] virtual std::string toString() const { return "MCTSGameState"; }
|
||||
};
|
||||
|
||||
} // namespace mcts
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_MCTS_GAME_STATE_HPP
|
||||
@@ -1,277 +0,0 @@
|
||||
//
|
||||
// Abstract MCTS Node structure for game-agnostic implementation
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_ABSTRACT_MCTSNODE_HPP
|
||||
#define EAGLE0_ABSTRACT_MCTSNODE_HPP
|
||||
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "MCTSAction.hpp"
|
||||
#include "MCTSGameState.hpp"
|
||||
#include "MCTSTypes.hpp"
|
||||
|
||||
namespace shardok {
|
||||
namespace mcts {
|
||||
|
||||
// Node type for MCTS tree
|
||||
enum class NodeType {
|
||||
DECISION, // Player chooses an action (standard MCTS node)
|
||||
CHANCE // Nature determines outcome (for probabilistic actions)
|
||||
};
|
||||
|
||||
// Abstract MCTS Node structure
|
||||
struct MCTSNode {
|
||||
// Node type
|
||||
NodeType nodeType = NodeType::DECISION;
|
||||
// Action information
|
||||
std::unique_ptr<MCTSAction> action; // The action that led to this node (null for root)
|
||||
size_t actionIndex = SIZE_MAX; // Index in the original actions array (SIZE_MAX for root)
|
||||
|
||||
// Score information
|
||||
double immediateScore = 0.0;
|
||||
double lookaheadScore = 0.0;
|
||||
|
||||
// Game state after this action
|
||||
std::unique_ptr<MCTSGameState> gameState;
|
||||
|
||||
// MCTS statistics
|
||||
int visitCount = 0;
|
||||
double totalReward = 0.0;
|
||||
double averageReward = 0.0;
|
||||
mutable double ucb1Value = 0.0;
|
||||
double actionWeight = 1.0; // Prior probability/weight for this action (from heuristics)
|
||||
|
||||
// Tree structure
|
||||
std::vector<std::unique_ptr<MCTSNode>> children;
|
||||
size_t nextUntriedActionIndex = 0; // Next action to expand
|
||||
size_t totalActions = 0; // Total number of available actions
|
||||
MCTSNode* parent = nullptr;
|
||||
|
||||
// Chance node specific fields (only used when nodeType == CHANCE)
|
||||
std::vector<double> outcomeProbabilities; // Probability of each outcome
|
||||
std::vector<double> outcomeRolls; // Representative roll for each outcome
|
||||
|
||||
// Game context
|
||||
MCTSPlayerId playerId;
|
||||
int depth = 0;
|
||||
bool isTerminal = false;
|
||||
int playerFlips = 0; // Number of times the active player has changed from root player
|
||||
bool isMaximizingPlayer = true; // True if this node is maximizing for root player
|
||||
|
||||
// Transposition detection
|
||||
uint64_t stateHash = 0;
|
||||
bool isRedundant = false; // True if this node represents a duplicate state
|
||||
|
||||
// Constructor for root node
|
||||
MCTSNode(std::unique_ptr<MCTSGameState> state, MCTSPlayerId pid, int d)
|
||||
: gameState(std::move(state)),
|
||||
playerId(pid),
|
||||
depth(d),
|
||||
playerFlips(0),
|
||||
isMaximizingPlayer(true) {
|
||||
if (gameState) {
|
||||
stateHash = gameState->hash();
|
||||
isTerminal = gameState->isTerminal();
|
||||
}
|
||||
}
|
||||
|
||||
// Constructor for child node
|
||||
MCTSNode(
|
||||
std::unique_ptr<MCTSAction> act,
|
||||
std::unique_ptr<MCTSGameState> state,
|
||||
MCTSPlayerId pid,
|
||||
int d,
|
||||
size_t actIdx = SIZE_MAX,
|
||||
int flips = 0,
|
||||
bool isMaximizing = true,
|
||||
double weight = 1.0)
|
||||
: action(std::move(act)),
|
||||
actionIndex(actIdx),
|
||||
gameState(std::move(state)),
|
||||
actionWeight(weight),
|
||||
playerId(pid),
|
||||
depth(d),
|
||||
playerFlips(flips),
|
||||
isMaximizingPlayer(isMaximizing) {
|
||||
if (gameState) {
|
||||
stateHash = gameState->hash();
|
||||
isTerminal = gameState->isTerminal();
|
||||
}
|
||||
}
|
||||
|
||||
// Iterative destructor to avoid stack overflow with deep trees
|
||||
~MCTSNode() {
|
||||
std::vector<std::unique_ptr<MCTSNode>> nodesToDestroy;
|
||||
nodesToDestroy.swap(children);
|
||||
|
||||
while (!nodesToDestroy.empty()) {
|
||||
std::vector<std::unique_ptr<MCTSNode>> currentBatch;
|
||||
currentBatch.swap(nodesToDestroy);
|
||||
|
||||
for (const auto& node : currentBatch) {
|
||||
if (node && !node->children.empty()) {
|
||||
for (auto& child : node->children) {
|
||||
nodesToDestroy.push_back(std::move(child));
|
||||
}
|
||||
node->children.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate UCB1 value for this node from parent's perspective
|
||||
// Uses prior-weighted formula similar to AlphaGo:
|
||||
// UCB = Q + c * P * sqrt(N_parent) / (1 + N_child)
|
||||
// Where P is the action weight (prior probability from heuristics)
|
||||
[[nodiscard]] double CalculateUCB1(
|
||||
const double explorationConstant,
|
||||
const int parentVisitCount,
|
||||
const bool parentIsMaximizing) const {
|
||||
// Exploitation: use lookahead score (minimax value)
|
||||
// For minimizing nodes, negate the score to prefer low child values
|
||||
const double exploitationValue = parentIsMaximizing ? lookaheadScore : -lookaheadScore;
|
||||
|
||||
// Exploration: prior-weighted formula (AlphaGo-style)
|
||||
// Actions with weight 0.0 (like FLEE_COMMAND) get no exploration bonus
|
||||
// Unvisited nodes get: c * weight * sqrt(N_parent)
|
||||
// This prevents bad actions from dominating exploration due to infinite UCB
|
||||
const double explorationValue = explorationConstant * actionWeight *
|
||||
std::sqrt(parentVisitCount) / (1.0 + visitCount);
|
||||
|
||||
return exploitationValue + explorationValue;
|
||||
}
|
||||
|
||||
// Check if this node can be expanded
|
||||
[[nodiscard]] bool CanExpand() const { return nextUntriedActionIndex < totalActions; }
|
||||
|
||||
// Check if this is a chance node
|
||||
[[nodiscard]] bool IsChanceNode() const { return nodeType == NodeType::CHANCE; }
|
||||
|
||||
// Check if this is a decision node
|
||||
[[nodiscard]] bool IsDecisionNode() const { return nodeType == NodeType::DECISION; }
|
||||
|
||||
// Get best child from chance node (probability-weighted selection)
|
||||
// For chance nodes, we want to explore outcomes proportionally to their probability
|
||||
[[nodiscard]] MCTSNode* GetBestChanceChild() const {
|
||||
if (children.empty() || !IsChanceNode()) return nullptr;
|
||||
|
||||
// Find the outcome that is most under-explored relative to its probability
|
||||
// Expected visits for outcome i: total_visits * probability[i]
|
||||
// Actual visits: child[i]->visitCount
|
||||
// Deficit: expected - actual
|
||||
size_t bestIndex = 0;
|
||||
double bestDeficit = -std::numeric_limits<double>::max();
|
||||
|
||||
for (size_t i = 0; i < children.size(); i++) {
|
||||
if (!children[i] || children[i]->isRedundant) continue;
|
||||
|
||||
const double expectedVisits = visitCount * outcomeProbabilities[i];
|
||||
const double actualVisits = static_cast<double>(children[i]->visitCount);
|
||||
const double deficit = expectedVisits - actualVisits;
|
||||
|
||||
if (deficit > bestDeficit) {
|
||||
bestDeficit = deficit;
|
||||
bestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return children[bestIndex].get();
|
||||
}
|
||||
|
||||
// Get best child based on UCB1
|
||||
[[nodiscard]] MCTSNode* GetBestChild(const double explorationConstant) const {
|
||||
if (children.empty()) return nullptr;
|
||||
|
||||
MCTSNode* bestChild = nullptr;
|
||||
double bestValue = -std::numeric_limits<double>::max();
|
||||
|
||||
for (auto& child : children) {
|
||||
// Skip redundant nodes
|
||||
if (child->isRedundant) continue;
|
||||
|
||||
// Calculate UCB1 value using the helper function
|
||||
const double value =
|
||||
child->CalculateUCB1(explorationConstant, visitCount, isMaximizingPlayer);
|
||||
|
||||
// Debug logging for UCB selection
|
||||
static bool enableUCBDebug = false;
|
||||
if (enableUCBDebug && child->visitCount > 0) {
|
||||
const double exploitationValue =
|
||||
isMaximizingPlayer ? child->lookaheadScore : -child->lookaheadScore;
|
||||
const double explorationValue =
|
||||
explorationConstant * std::sqrt(std::log(visitCount) / child->visitCount);
|
||||
printf(" UCB: %s lookahead=%.2f expl=%.2f (+%.2f) = %.2f [%s]\n",
|
||||
isMaximizingPlayer ? "MAX" : "MIN",
|
||||
child->lookaheadScore,
|
||||
exploitationValue,
|
||||
explorationValue,
|
||||
value,
|
||||
child->action ? child->action->getDescription().c_str() : "root");
|
||||
}
|
||||
|
||||
if (value > bestValue) {
|
||||
bestValue = value;
|
||||
bestChild = child.get();
|
||||
}
|
||||
}
|
||||
|
||||
return bestChild;
|
||||
}
|
||||
|
||||
// Get best child based on visit count (for final selection)
|
||||
[[nodiscard]] MCTSNode* GetBestFinalChild() const {
|
||||
if (children.empty()) return nullptr;
|
||||
|
||||
MCTSNode* bestChild = nullptr;
|
||||
int bestVisits = 0;
|
||||
double bestScore = isMaximizingPlayer ? -std::numeric_limits<double>::max()
|
||||
: std::numeric_limits<double>::max();
|
||||
|
||||
for (const auto& child : children) {
|
||||
// Skip redundant nodes
|
||||
if (child->isRedundant) continue;
|
||||
|
||||
// Prefer most-visited node (robust child selection)
|
||||
if (child->visitCount > bestVisits) {
|
||||
bestVisits = child->visitCount;
|
||||
bestScore = child->lookaheadScore;
|
||||
bestChild = child.get();
|
||||
} else if (child->visitCount == bestVisits) {
|
||||
// Tie-break on lookahead score (minimax value, not poisoned average)
|
||||
// Maximizing: prefer higher score (better for root player)
|
||||
// Minimizing: prefer lower score (worse for root player)
|
||||
const bool shouldReplace = isMaximizingPlayer ? (child->lookaheadScore > bestScore)
|
||||
: (child->lookaheadScore < bestScore);
|
||||
if (shouldReplace) {
|
||||
bestScore = child->lookaheadScore;
|
||||
bestChild = child.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no child was visited, fall back to lookahead score
|
||||
if (!bestChild && !children.empty()) {
|
||||
for (const auto& child : children) {
|
||||
if (child->isRedundant) continue;
|
||||
|
||||
const bool shouldReplace = isMaximizingPlayer ? (child->lookaheadScore > bestScore)
|
||||
: (child->lookaheadScore < bestScore);
|
||||
if (shouldReplace) {
|
||||
bestScore = child->lookaheadScore;
|
||||
bestChild = child.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestChild;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace mcts
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_ABSTRACT_MCTSNODE_HPP
|
||||
@@ -1,60 +0,0 @@
|
||||
//
|
||||
// Core types for abstract MCTS implementation
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_MCTS_TYPES_HPP
|
||||
#define EAGLE0_MCTS_TYPES_HPP
|
||||
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace shardok {
|
||||
namespace mcts {
|
||||
|
||||
// Exception thrown when MCTS encounters an internal error that indicates a bug
|
||||
class MCTSInternalError : public std::logic_error {
|
||||
public:
|
||||
explicit MCTSInternalError(const std::string& message) : std::logic_error(message) {}
|
||||
};
|
||||
|
||||
// Abstract player identifier type
|
||||
using MCTSPlayerId = int;
|
||||
|
||||
// Simulation policy for MCTS rollouts
|
||||
enum class MCTSSimulationPolicy {
|
||||
RANDOM, // Pure random selection
|
||||
FILTERED_RANDOM, // Random from filtered actions
|
||||
BEST_IMMEDIATE, // Choose best immediate score
|
||||
WEIGHTED_BEST_IMMEDIATE, // Random weighted by score ranking
|
||||
WEIGHTED_HEURISTIC // Random weighted by fast heuristics (no score evaluation)
|
||||
};
|
||||
|
||||
// Backpropagation policy for MCTS tree updates
|
||||
enum class MCTSBackpropagationPolicy {
|
||||
AVERAGING, // Traditional MCTS averaging (for stochastic/single-player games)
|
||||
MINIMAX // Minimax backup (for deterministic adversarial games)
|
||||
};
|
||||
|
||||
// Configuration for MCTS algorithm
|
||||
struct MCTSConfig {
|
||||
double explorationConstant = 1.414; // UCB1 constant (sqrt(2) by default)
|
||||
int maxSimulationDepth = 1000; // Maximum depth for rollout
|
||||
int maxTreeDepth = 2000; // Maximum tree depth to prevent stack overflow
|
||||
bool useMultithreading = true; // Enable parallel MCTS
|
||||
int numThreads = 16; // Number of threads for parallel MCTS
|
||||
MCTSSimulationPolicy simulationPolicy = MCTSSimulationPolicy::BEST_IMMEDIATE;
|
||||
MCTSBackpropagationPolicy backpropagationPolicy = MCTSBackpropagationPolicy::AVERAGING;
|
||||
int maxPlayerFlips = 0; // Maximum number of player changes for tree expansion
|
||||
// (0 = expand through current player's turn only,
|
||||
// 1 = expand through opponent's first response, etc.)
|
||||
int maxSimulationFlips = 0; // Maximum player flips for leaf evaluation
|
||||
// When evaluating a leaf at playerFlips < maxSimulationFlips,
|
||||
// simulate forward to this phase for fair comparison
|
||||
// (default 0 = evaluate leaves as-is, backward compatible)
|
||||
std::string debugDumpPath = ""; // If non-empty, dump MCTS tree to this file path
|
||||
};
|
||||
|
||||
} // namespace mcts
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_MCTS_TYPES_HPP
|
||||
@@ -1,8 +0,0 @@
|
||||
load("@rules_cc//cc:defs.bzl", "cc_library")
|
||||
|
||||
cc_library(
|
||||
name = "tree_indent_util",
|
||||
srcs = ["TreeIndentUtil.cpp"],
|
||||
hdrs = ["TreeIndentUtil.hpp"],
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
@@ -1,53 +0,0 @@
|
||||
//
|
||||
// Utility functions for processing tree indentation with UTF-8 box drawing characters
|
||||
//
|
||||
|
||||
#include "TreeIndentUtil.hpp"
|
||||
|
||||
namespace mcts::util {
|
||||
|
||||
namespace {
|
||||
// Box drawing characters for tree visualization
|
||||
constexpr const char* kBranch = "\xE2\x94\x9C"; // ├
|
||||
constexpr const char* kCorner = "\xE2\x94\x94"; // └
|
||||
constexpr const char* kVertical = "\xE2\x94\x82"; // │
|
||||
constexpr const char* kHorizontal = "\xE2\x94\x80"; // ─
|
||||
} // namespace
|
||||
|
||||
std::string BuildTreeIndent(int indentLevel, bool isLastChild) {
|
||||
std::string indent;
|
||||
|
||||
for (int i = 0; i < indentLevel; ++i) {
|
||||
if (i == indentLevel - 1) {
|
||||
indent += isLastChild ? kCorner : kBranch;
|
||||
indent += kHorizontal;
|
||||
indent += " ";
|
||||
} else {
|
||||
indent += " ";
|
||||
}
|
||||
}
|
||||
|
||||
return indent;
|
||||
}
|
||||
|
||||
std::string ConvertBranchToContinuation(const std::string& indent) {
|
||||
std::string result = indent;
|
||||
|
||||
const std::string replacement = std::string(kVertical) + " ";
|
||||
|
||||
// Replace ├ and └ with │
|
||||
size_t pos = 0;
|
||||
while ((pos = result.find(kBranch, pos)) != std::string::npos) {
|
||||
result.replace(pos, 3, replacement); // UTF-8 chars are 3 bytes
|
||||
pos += replacement.size();
|
||||
}
|
||||
pos = 0;
|
||||
while ((pos = result.find(kCorner, pos)) != std::string::npos) {
|
||||
result.replace(pos, 3, replacement);
|
||||
pos += replacement.size();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace mcts::util
|
||||
@@ -1,22 +0,0 @@
|
||||
//
|
||||
// Utility functions for processing tree indentation with UTF-8 box drawing characters
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_TREE_INDENT_UTIL_HPP
|
||||
#define EAGLE0_TREE_INDENT_UTIL_HPP
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace mcts::util {
|
||||
|
||||
// Builds tree indentation string for a node at a given depth
|
||||
// Returns string like " ├─ " or " └─ " with proper spacing
|
||||
std::string BuildTreeIndent(int indentLevel, bool isLastChild);
|
||||
|
||||
// Converts tree branch characters (├ and └) to continuation lines (│) for sub-content
|
||||
// This preserves the tree structure when displaying additional info below a node
|
||||
std::string ConvertBranchToContinuation(const std::string& indent);
|
||||
|
||||
} // namespace mcts::util
|
||||
|
||||
#endif // EAGLE0_TREE_INDENT_UTIL_HPP
|
||||
@@ -14,7 +14,6 @@ cc_library(
|
||||
"//src/main/cpp/net/eagle0/shardok/library:engine",
|
||||
"//src/main/cpp/net/eagle0/shardok/server:eagle_interface_grpc_server",
|
||||
"//src/main/cpp/net/eagle0/shardok/server:server_configuration",
|
||||
"//src/main/cpp/net/eagle0/shardok/server:token_auth",
|
||||
"//src/main/protobuf/net/eagle0/common:common_unit_cc_proto",
|
||||
"@grpc//:grpc++",
|
||||
],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user