Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 4.5 5cb23464e5 Fix nginx deploy: use SCP to copy config files
The production server doesn't have a git repo, so we can't use git
commands to update files. Instead, use appleboy/scp-action to copy
nginx.conf and docker-compose.prod.yml from the runner to the server,
then restart nginx.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 14:02:15 -08:00
3742 changed files with 26495 additions and 70519 deletions
-1
View File
@@ -10,4 +10,3 @@
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
*.ogg filter=lfs diff=lfs merge=lfs -text
@@ -1,39 +0,0 @@
name: Artifact Storage Check
on:
schedule:
# Run every 6 hours
- cron: '0 */6 * * *'
workflow_dispatch:
jobs:
check-storage:
runs-on: ubuntu-latest
steps:
- name: Check artifact storage size
env:
GH_TOKEN: ${{ github.token }}
run: |
# Calculate total artifact storage
total_bytes=$(gh api "repos/${{ github.repository }}/actions/artifacts" \
--paginate -q '.artifacts[].size_in_bytes' | awk '{sum+=$1} END {print sum}')
total_mb=$((total_bytes / 1024 / 1024))
echo "Total artifact storage: ${total_mb} MB"
# Fail if over 500MB
if [ "$total_mb" -gt 500 ]; then
echo "::error::Artifact storage is ${total_mb} MB, which exceeds the 500 MB threshold!"
echo ""
echo "Largest artifacts:"
# Save to temp file to avoid SIGPIPE/broken pipe errors with head
gh api "repos/${{ github.repository }}/actions/artifacts" \
--paginate -q '.artifacts[] | "\(.size_in_bytes)\t\(.name)\t\(.created_at)"' > /tmp/artifacts.txt
sort -rn /tmp/artifacts.txt | head -20 | \
awk -F'\t' '{printf "%d MB\t%s\t%s\n", $1/1024/1024, $2, $3}'
rm -f /tmp/artifacts.txt
exit 1
fi
echo "Storage is within acceptable limits."
+17 -1
View File
@@ -9,9 +9,10 @@ on:
- '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'
- 'src/main/resources/net/eagle0/attributions.json'
- 'ci/BUILD.bazel'
- '.github/workflows/auth_build.yml'
- 'nginx/nginx.conf'
- 'docker-compose.prod.yml'
workflow_dispatch:
inputs:
push_images:
@@ -121,6 +122,16 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Copy config files to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
source: "nginx/nginx.conf,docker-compose.prod.yml"
target: "/opt/eagle0"
strip_components: 0
- name: Deploy auth service to production
uses: appleboy/ssh-action@v1.0.3
with:
@@ -198,5 +209,10 @@ jobs:
# Show container status
docker compose -f docker-compose.prod.yml ps auth
# Restart nginx with updated config (copied via SCP step)
echo "=== Restarting nginx ==="
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate nginx
echo "Nginx restarted"
# Cleanup old images
docker image prune -f
-36
View File
@@ -1,36 +0,0 @@
name: Bazel Cache Cleanup
on:
schedule:
# Run weekly on Sunday at 00:00 UTC
- cron: '0 0 * * 0'
workflow_dispatch: # Allow manual trigger
jobs:
cleanup:
runs-on: [self-hosted, bazel]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Show disk usage before cleanup
run: |
echo "=== Disk usage before cleanup ==="
BAZEL_USER_ROOT="/private/var/tmp/_bazel_$(whoami)"
echo "Bazel user root: $BAZEL_USER_ROOT"
du -sh "$BAZEL_USER_ROOT" 2>/dev/null || echo "Not found"
df -h .
- name: Run bazel clean
run: |
echo "=== Running bazel clean ==="
bazel clean
echo "Clean complete"
- name: Show disk usage after cleanup
run: |
echo "=== Disk usage after cleanup ==="
BAZEL_USER_ROOT="/private/var/tmp/_bazel_$(whoami)"
du -sh "$BAZEL_USER_ROOT" 2>/dev/null || echo "Not found"
df -h .
+5 -4
View File
@@ -33,9 +33,9 @@ jobs:
uses: actions/checkout@v4
with:
lfs: false
- name: Check BUILD.bazel dependencies
run: ./scripts/check_build_deps.sh --strict
- 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()
@@ -75,7 +75,6 @@ jobs:
with:
name: test.json
path: test.json
retention-days: 5
- name: Archive failed test logs
if: always()
uses: actions/upload-artifact@v4
@@ -83,4 +82,6 @@ jobs:
name: failed-test-logs
path: failed_test_logs/
if-no-files-found: ignore
retention-days: 5
- name: Fail if tests failed
if: steps.test.outcome == 'failure'
run: exit 1
-2
View File
@@ -37,7 +37,6 @@ jobs:
with:
name: ubuntu-noble-sysroot-amd64
path: tools/sysroot/output/
retention-days: 1
- name: Install AWS CLI
run: |
@@ -99,7 +98,6 @@ jobs:
with:
name: ubuntu-noble-sysroot-arm64
path: tools/sysroot/output/
retention-days: 1
- name: Install AWS CLI
run: |
+6 -35
View File
@@ -6,11 +6,9 @@ on:
paths:
# Note: C++ changes trigger shardok_arm64_build.yml instead
# Note: Auth changes trigger auth_build.yml instead
# Note: Windows installer changes trigger installer_build.yml instead
- 'src/main/go/**'
- '!src/main/go/net/eagle0/authservice/**'
- '!src/main/go/net/eagle0/authcli/**'
- '!src/main/go/net/eagle0/clients/**'
- 'src/main/scala/**'
- 'src/main/protobuf/**'
- '!src/main/protobuf/net/eagle0/eagle/api/auth.proto'
@@ -68,11 +66,11 @@ jobs:
//ci:eagle_server_image \
//ci:admin_server_image \
//ci:jfr_sidecar_image \
//ci:warmup_tar
//src/main/go/net/eagle0/warmup:warmup_linux_amd64
# Extract warmup binary from tar for deployment
# Copy warmup binary to scripts/ for deployment
mkdir -p scripts/bin
tar -xf bazel-bin/ci/warmup_tar.tar -C scripts/bin --strip-components=1
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)
@@ -161,8 +159,6 @@ jobs:
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 }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_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 }}
@@ -178,8 +174,6 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_SIGNIN_KEY_ID: ${{ secrets.APPLE_SIGNIN_KEY_ID }}
APPLE_SIGNIN_PRIVATE_KEY: ${{ secrets.APPLE_SIGNIN_PRIVATE_KEY }}
TWITCH_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }}
TWITCH_CLIENT_SECRET: ${{ secrets.TWITCH_CLIENT_SECRET }}
SHARDOK_ADDRESS: ${{ secrets.SHARDOK_ADDRESS }}
SHARDOK_AUTH_TOKEN: ${{ secrets.SHARDOK_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
@@ -188,7 +182,6 @@ jobs:
FASTMAIL_FROM_NAME: ${{ secrets.FASTMAIL_FROM_NAME }}
DO_DROPLET_IP: ${{ secrets.DO_DROPLET_IP }}
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
NOTIFY_SECRET: ${{ secrets.EAGLE_NOTIFY_SECRET }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -202,9 +195,9 @@ jobs:
- name: Build warmup tool
run: |
bazel build //ci:warmup_tar
bazel build //src/main/go/net/eagle0/warmup:warmup_linux_amd64
mkdir -p scripts/bin
tar -xf bazel-bin/ci/warmup_tar.tar -C scripts/bin --strip-components=1
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: |
@@ -218,7 +211,7 @@ jobs:
# 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 scripts/eagle-exec.sh scripts/eagle-logs.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/
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/
- name: Deploy to production droplet
@@ -282,8 +275,6 @@ jobs:
export ADMIN_IMAGE="${ADMIN_IMAGE}"
export JFR_SIDECAR_IMAGE="${JFR_SIDECAR_IMAGE}"
export OPENAI_API_KEY="${OPENAI_API_KEY}"
export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY}"
export GEMINI_API_KEY="${GEMINI_API_KEY}"
export GPT_MODEL_NAME="${GPT_MODEL_NAME:-gpt-4o}"
export EAGLE_ENABLE_S3="${EAGLE_ENABLE_S3:-false}"
export DO_SPACES_ACCESS_KEY="${DO_SPACES_ACCESS_KEY}"
@@ -299,8 +290,6 @@ jobs:
export APPLE_TEAM_ID="${APPLE_TEAM_ID}"
export APPLE_SIGNIN_KEY_ID="${APPLE_SIGNIN_KEY_ID}"
export APPLE_SIGNIN_PRIVATE_KEY="${APPLE_SIGNIN_PRIVATE_KEY}"
export TWITCH_CLIENT_ID="${TWITCH_CLIENT_ID}"
export TWITCH_CLIENT_SECRET="${TWITCH_CLIENT_SECRET}"
export SHARDOK_ADDRESS="${SHARDOK_ADDRESS}"
export SHARDOK_AUTH_TOKEN="${SHARDOK_AUTH_TOKEN}"
export SENTRY_DSN="${SENTRY_DSN}"
@@ -308,7 +297,6 @@ jobs:
export FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
export FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
export DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
export NOTIFY_SECRET="${NOTIFY_SECRET}"
# Check Docker has IPv6 support
if ! cat /etc/docker/daemon.json 2>/dev/null | grep -q '"ip6tables"'; then
@@ -332,8 +320,6 @@ jobs:
echo "Pulling Admin image..."
./crane pull "\${ADMIN_IMAGE}" admin.tar && docker load -i admin.tar && rm admin.tar
# Tag as :latest locally so docker-compose fallback uses correct image
docker tag "\${ADMIN_IMAGE}" registry.digitalocean.com/eagle0/admin-server:latest
echo "Pulling JFR Sidecar image..."
./crane pull "\${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar && docker load -i jfr-sidecar.tar && rm jfr-sidecar.tar
@@ -386,21 +372,6 @@ jobs:
docker compose -f docker-compose.prod.yml ps
docker compose -f docker-compose.prod.yml images
# Verify admin container is running the correct image
echo "=== Verifying admin container image ==="
ADMIN_RUNNING_DIGEST=\$(docker inspect admin-server --format '{{.Image}}')
ADMIN_EXPECTED_DIGEST=\$(docker inspect "\${ADMIN_IMAGE}" --format '{{.Id}}')
echo "Expected image: \${ADMIN_IMAGE}"
echo "Expected digest: \${ADMIN_EXPECTED_DIGEST}"
echo "Running digest: \${ADMIN_RUNNING_DIGEST}"
if [ "\${ADMIN_RUNNING_DIGEST}" != "\${ADMIN_EXPECTED_DIGEST}" ]; then
echo "ERROR: Admin container is running wrong image!"
echo "Container entrypoint:"
docker inspect admin-server --format '{{.Config.Entrypoint}}'
exit 1
fi
echo "Admin image verification passed"
# Cleanup
docker container prune -f
docker image prune -f
-37
View File
@@ -1,37 +0,0 @@
name: Eagle Build
on:
push:
branches: [ "main" ]
paths:
- 'src/main/scala/**'
- 'src/main/protobuf/net/eagle0/eagle/**'
- 'src/main/protobuf/net/eagle0/common/**'
- 'WORKSPACE'
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.github/workflows/eagle_build.yml'
pull_request:
paths:
- 'src/main/scala/**'
- 'src/main/protobuf/net/eagle0/eagle/**'
- 'src/main/protobuf/net/eagle0/common/**'
- 'WORKSPACE'
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.github/workflows/eagle_build.yml'
permissions:
contents: read
jobs:
build:
runs-on: [self-hosted, bazel]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Eagle server
run: bazel build //src/main/scala/net/eagle0/eagle:eagle_server
+38 -52
View File
@@ -5,16 +5,15 @@ on:
branches: [ "main" ]
paths:
- ".github/workflows/installer_build.yml"
- "src/main/go/net/eagle0/clients/win/installer/**"
- "src/main/csharp/net/eagle0/clients/win/installer/**"
pull_request:
paths:
- ".github/workflows/installer_build.yml"
- "src/main/go/net/eagle0/clients/win/installer/**"
- "src/main/csharp/net/eagle0/clients/win/installer/**"
workflow_dispatch:
permissions:
contents: read
actions: write # Required to delete artifacts after deploy
jobs:
build-installer:
@@ -26,47 +25,47 @@ jobs:
lfs: false
clean: false
- name: Build Go installer for Windows
- name: Setup .NET 8
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Inject manifest public key
env:
MANIFEST_PUBLIC_KEY: ${{ secrets.MANIFEST_PUBLIC_KEY }}
run: |
# Require manifest public key for production builds
if [ -z "$MANIFEST_PUBLIC_KEY" ]; then
echo "ERROR: MANIFEST_PUBLIC_KEY secret is not set"
echo "The installer requires a public key for manifest signature verification"
exit 1
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
# Build Windows installer with WebView GUI (uses CGO cross-compilation)
# Use --action_env to pass the signing key into the genrule sandbox
bazel build //src/main/go/net/eagle0/clients/win/installer:eagle_installer_windows_amd64_webview --stamp --action_env=MANIFEST_PUBLIC_KEY
- name: Restore dependencies
run: dotnet restore src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/EagleInstaller.csproj
# Copy to output directory
rm -rf ./installer-output
mkdir -p ./installer-output
cp bazel-bin/src/main/go/net/eagle0/clients/win/installer/Eagle0.exe ./installer-output/Eagle0.exe
echo "Go installer size: $(ls -lh ./installer-output/Eagle0.exe | awk '{print $5}')"
- name: Build installer
run: dotnet publish src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/EagleInstaller.csproj -c Release -r win-x64 --self-contained true --output ./installer-output
- name: Archive installer binary
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: eagle-installer
path: ./installer-output/
retention-days: 1
path: ./installer-output/EagleInstaller.exe
- name: Verify installer exists
if: success()
run: |
echo "=== Installer output directory ==="
ls -lh ./installer-output/
if [ ! -f "./installer-output/Eagle0.exe" ]; then
echo "ERROR: Eagle0.exe not found"
if [ ! -f "./installer-output/EagleInstaller.exe" ]; then
echo "ERROR: EagleInstaller.exe not found at expected location"
echo "Directory contents:"
ls -la ./installer-output/
exit 1
fi
echo "Installer found"
echo "Installer found at correct location"
- name: Deploy installer
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
@@ -74,24 +73,25 @@ jobs:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: |
INSTALLER_PATH="$(pwd)/installer-output/Eagle0.exe"
echo "Deploying Go installer to installer/Eagle0.exe"
bazel run //src/main/go/net/eagle0/build/installer_build_handler:installer_build_handler -- "$INSTALLER_PATH" "installer/Eagle0.exe"
INSTALLER_PATH="$(pwd)/installer-output/EagleInstaller.exe"
echo "Using absolute path: $INSTALLER_PATH"
bazel run //src/main/go/net/eagle0/build/installer_build_handler:installer_build_handler -- "$INSTALLER_PATH"
- name: Update manifest
- name: Update unified manifest
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: |
INSTALLER_SHA=$(sha256sum ./installer-output/Eagle0.exe | cut -d' ' -f1)
# 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/Eagle0.exe" >> /tmp/installer_manifest.txt
echo "installer_url=installer/EagleInstaller.exe" >> /tmp/installer_manifest.txt
echo "=== Manifest content ==="
echo "=== Installer manifest content ==="
cat /tmp/installer_manifest.txt
echo "========================"
echo "=================================="
# Write signing key to temp file (if available)
SIGNING_ARGS=""
@@ -104,22 +104,8 @@ jobs:
echo "Warning: MANIFEST_SIGNING_KEY not set, manifest will be unsigned"
fi
# Update the v2 manifest at installer/v2/eagle0_manifest.txt
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- installer-v2 /tmp/installer_manifest.txt $SIGNING_ARGS
# 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
rm -f /tmp/manifest_signing_key
- name: Delete all installer artifacts
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
GH_TOKEN: ${{ github.token }}
run: |
# Delete ALL eagle-installer artifacts to free up storage
echo "Fetching all eagle-installer artifacts..."
artifact_ids=$(gh api "repos/${{ github.repository }}/actions/artifacts" \
--paginate -q '.artifacts[] | select(.name == "eagle-installer") | .id')
for id in $artifact_ids; do
echo "Deleting artifact ID: $id"
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$id" || true
done
echo "Cleanup complete"
# Cleanup
rm -f /tmp/manifest_signing_key
@@ -1,89 +0,0 @@
name: iOS Addressables Build
on:
push:
branches: [ "main" ]
paths:
- ".github/workflows/ios_addressables_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/AddressableAssetsData/**"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Music/**"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Editor/BuildScript.cs"
- "ci/github_actions/build_ios_addressables.sh"
- "ci/github_actions/upload_addressables.sh"
pull_request:
paths:
- ".github/workflows/ios_addressables_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/AddressableAssetsData/**"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Music/**"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Editor/BuildScript.cs"
- "ci/github_actions/build_ios_addressables.sh"
- "ci/github_actions/upload_addressables.sh"
workflow_dispatch:
permissions:
contents: read
env:
# Runner-specific build directory to allow parallel builds on multiple runners
EAGLE0_BUILD_DIR: /tmp/eagle0-${{ github.run_id }}
jobs:
build-ios-addressables:
runs-on: [self-hosted, macOS, unity-mac]
steps:
- name: Prune stale PR refs
run: |
# Self-hosted runners persist .git between runs. When a PR is updated,
# old local refs (refs/remotes/pull/*/merge) may point to commits whose
# objects were never fetched or have been pruned. Remove these stale refs
# before checkout to prevent "missing object" errors.
if [ -d ".git" ]; then
echo "Pruning stale PR refs..."
git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
xargs -r git update-ref -d 2>/dev/null || true
fi
- uses: actions/checkout@v4
with:
lfs: false # Fetch LFS after checkout to avoid stale ref issues
clean: true
- name: Fetch LFS files
run: |
git lfs install
git lfs pull
- name: Ensure Unity version installed
run: ./ci/github_actions/ensure_unity_installed.sh ios
- name: Restore Library/
env:
UNITY_CACHE_PLATFORM: ios
run: ./ci/github_actions/restore_library.sh
- name: Build iOS Addressables
run: ./ci/github_actions/build_ios_addressables.sh "$EAGLE0_BUILD_DIR/editor_ios_addressables.log"
- name: Persist Library/
if: success()
env:
UNITY_CACHE_PLATFORM: ios
run: ./ci/github_actions/persist_library.sh
- name: Upload Addressables to CDN
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: ./ci/github_actions/upload_addressables.sh iOS
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: editor_ios_addressables.log
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_ios_addressables.log
retention-days: 5
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
-226
View File
@@ -1,226 +0,0 @@
name: iOS TestFlight
on:
workflow_dispatch:
inputs:
skip_upload:
description: 'Skip TestFlight upload (build and archive only)'
required: false
default: 'false'
type: boolean
permissions:
contents: read
actions: write
env:
# Runner-specific build directory to allow parallel builds on multiple runners
EAGLE0_BUILD_DIR: /tmp/eagle0-${{ github.run_id }}
# Runner-specific keychain to avoid conflicts when multiple runners sign simultaneously
KEYCHAIN_NAME: ios-build-${{ github.run_id }}.keychain
jobs:
build-unity:
runs-on: [self-hosted, macOS, unity-mac]
outputs:
xcode_project_path: ${{ steps.build.outputs.xcode_project_path }}
steps:
- name: Prune stale PR refs
run: |
# Self-hosted runners persist .git between runs. When a PR is updated,
# old local refs (refs/remotes/pull/*/merge) may point to commits whose
# objects were never fetched or have been pruned. Remove these stale refs
# before checkout to prevent "missing object" errors.
if [ -d ".git" ]; then
echo "Pruning stale PR refs..."
git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
xargs -r git update-ref -d 2>/dev/null || true
fi
- uses: actions/checkout@v4
with:
lfs: false # Fetch LFS after checkout to avoid stale ref issues
clean: true
fetch-depth: 0
- name: Fetch LFS files
run: |
git lfs install
git lfs pull
- name: Ensure Unity version installed
run: ./ci/github_actions/ensure_unity_installed.sh ios
- name: Restore Library/
env:
UNITY_CACHE_PLATFORM: ios
run: ./ci/github_actions/restore_library.sh
- name: Build iOS Unity Project
id: build
run: |
./ci/github_actions/build_unity_ios.sh "$EAGLE0_BUILD_DIR/eagle0iOS"
echo "xcode_project_path=$EAGLE0_BUILD_DIR/eagle0iOS" >> $GITHUB_OUTPUT
- name: Persist Library/
if: success()
env:
UNITY_CACHE_PLATFORM: ios
run: ./ci/github_actions/persist_library.sh
- name: Upload Addressables to CDN
if: success()
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: ./ci/github_actions/upload_addressables.sh iOS
- name: Zip Xcode project for artifact
run: |
cd $EAGLE0_BUILD_DIR
# Use tar for speed - Xcode projects have many small files
tar -czf eagle0iOS.tar.gz eagle0iOS
- name: Upload Xcode project
uses: actions/upload-artifact@v4
with:
name: xcode-project-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0iOS.tar.gz
retention-days: 1
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: editor_ios.log
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_ios.log
retention-days: 5
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
archive-and-upload:
needs: build-unity
runs-on: [self-hosted, macOS, unity-mac]
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
ci
scripts
- name: Clean download directory
run: rm -rf $EAGLE0_BUILD_DIR/eagle0iOS
- name: Download Xcode project
uses: actions/download-artifact@v4
with:
name: xcode-project-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}
- name: Extract Xcode project
run: |
cd $EAGLE0_BUILD_DIR
tar -xzf eagle0iOS.tar.gz
rm eagle0iOS.tar.gz
ls -la eagle0iOS/
- name: Install Signing Certificate
env:
IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }}
IOS_CERTIFICATE_PWD: ${{ secrets.IOS_CERTIFICATE_PWD }}
run: |
# Generate random keychain password
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> $GITHUB_ENV
# Decode certificate
echo "$IOS_CERTIFICATE" | base64 --decode > certificate.p12
# Delete any existing keychain from previous runs
security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true
# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
security default-keychain -s "$KEYCHAIN_NAME"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
security set-keychain-settings -t 3600 -u "$KEYCHAIN_NAME"
# Import certificate
security import certificate.p12 -k "$KEYCHAIN_NAME" -P "$IOS_CERTIFICATE_PWD" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
# Add keychain to search list
security list-keychains -d user -s "$KEYCHAIN_NAME" login.keychain
rm certificate.p12
- name: Install Provisioning Profile
env:
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
run: |
# Decode provisioning profile
echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > profile.mobileprovision
# Extract UUID from provisioning profile
PROFILE_UUID=$(/usr/libexec/PlistBuddy -c "Print :UUID" /dev/stdin <<< $(security cms -D -i profile.mobileprovision))
echo "PROFILE_UUID=$PROFILE_UUID" >> $GITHUB_ENV
# Install to standard location
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/$PROFILE_UUID.mobileprovision
rm profile.mobileprovision
echo "Installed provisioning profile: $PROFILE_UUID"
- name: Archive and Export IPA
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
chmod +x ./ci/github_actions/archive_ios.sh
./ci/github_actions/archive_ios.sh "$EAGLE0_BUILD_DIR/eagle0iOS" "$EAGLE0_BUILD_DIR/archive" "$APPLE_TEAM_ID" "$PROFILE_UUID"
- name: Upload to TestFlight
if: ${{ github.event.inputs.skip_upload != 'true' }}
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
run: |
chmod +x ./ci/github_actions/upload_testflight.sh
./ci/github_actions/upload_testflight.sh "$EAGLE0_BUILD_DIR/archive/eagle0.ipa"
- name: Upload IPA artifact
# Only keep artifact if we skipped TestFlight upload (for debugging)
if: success() && github.event.inputs.skip_upload == 'true'
uses: actions/upload-artifact@v4
with:
name: eagle0-ios-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/archive/eagle0.ipa
retention-days: 1
- name: Cleanup Keychain
if: always()
run: |
security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
cleanup:
needs: [build-unity, archive-and-upload]
if: always()
runs-on: ubuntu-latest
steps:
- name: Delete intermediate artifacts
env:
GH_TOKEN: ${{ github.token }}
run: |
artifact_name="xcode-project-${{ github.run_id }}"
echo "Deleting artifact: $artifact_name"
artifact_id=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" \
-q ".artifacts[] | select(.name == \"$artifact_name\") | .id")
if [ -n "$artifact_id" ]; then
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id" || true
fi
+35 -157
View File
@@ -20,9 +20,6 @@ on:
- "scripts/notarize_wait.sh"
- "ci/github_actions/build_mac.sh"
- "ci/github_actions/build_unity_mac.sh"
- "ci/github_actions/upload_addressables.sh"
- "ci/github_actions/ensure_unity_installed.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
- "ci/mac/**"
pull_request:
paths:
@@ -41,9 +38,6 @@ on:
- "scripts/notarize_wait.sh"
- "ci/github_actions/build_mac.sh"
- "ci/github_actions/build_unity_mac.sh"
- "ci/github_actions/upload_addressables.sh"
- "ci/github_actions/ensure_unity_installed.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
- "ci/mac/**"
workflow_dispatch:
inputs:
@@ -55,13 +49,6 @@ on:
permissions:
contents: read
actions: write # Required to delete artifacts after deploy
env:
# Runner-specific build directory to allow parallel builds on multiple runners
EAGLE0_BUILD_DIR: /tmp/eagle0-${{ github.run_id }}
# Runner-specific keychain to avoid conflicts when multiple runners sign simultaneously
KEYCHAIN_NAME: build-${{ github.run_id }}.keychain
jobs:
build-and-sign:
@@ -71,30 +58,14 @@ jobs:
should_deploy: ${{ steps.check-deploy.outputs.should_deploy }}
steps:
- name: Prune stale PR refs
run: |
# Self-hosted runners persist .git between runs. When a PR is updated,
# old local refs (refs/remotes/pull/*/merge) may point to commits whose
# objects were never fetched or have been pruned. Remove these stale refs
# before checkout to prevent "missing object" errors.
if [ -d ".git" ]; then
echo "Pruning stale PR refs..."
git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
xargs -r git update-ref -d 2>/dev/null || true
fi
- uses: actions/checkout@v4
with:
lfs: false # Fetch LFS after checkout to avoid stale ref issues
lfs: true
clean: true # Remove untracked files like old SparklePlugin.bundle
fetch-depth: 0 # For version numbering from git history
- name: Fetch LFS files
run: |
git lfs install
git lfs pull
- name: Ensure Unity version installed
run: ./ci/github_actions/ensure_unity_installed.sh mac
- name: Pull LFS files
run: git lfs pull
- name: Restore Library/
env:
@@ -102,7 +73,7 @@ jobs:
run: ./ci/github_actions/restore_library.sh
- name: Build Mac Unity
run: ./ci/github_actions/build_unity_mac.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC"
run: ./ci/github_actions/build_unity_mac.sh "/tmp/eagle0/eagle0MAC"
- name: Persist Library/
if: success()
@@ -110,20 +81,13 @@ jobs:
UNITY_CACHE_PLATFORM: mac
run: ./ci/github_actions/persist_library.sh
- name: Upload Addressables to CDN
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: ./ci/github_actions/upload_addressables.sh StandaloneOSX
- 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 "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app"
./scripts/inject_sparkle.sh "/tmp/eagle0/eagle0MAC/eagle0.app"
- name: Check if should deploy
id: check-deploy
@@ -152,31 +116,18 @@ jobs:
echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12
# Delete any existing keychain from previous runs
security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true
security delete-keychain build.keychain 2>/dev/null || true
# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
security default-keychain -s "$KEYCHAIN_NAME"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
# Import certificate
echo "=== Importing certificate ==="
security import certificate.p12 -k "$KEYCHAIN_NAME" -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign -T /usr/bin/security
security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign
# Allow codesign to access keychain
echo "=== Setting key partition list ==="
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
# Add keychain to search list (required for codesign to find certificates)
echo "=== Adding keychain to search list ==="
security list-keychains -d user -s "$KEYCHAIN_NAME" login.keychain
# Debug: Check what's in the keychain after import
echo "=== Debug: Identities in build keychain ==="
KEYCHAIN_PATH="$HOME/Library/Keychains/$KEYCHAIN_NAME-db"
security find-identity -v -p codesigning "$KEYCHAIN_PATH" || true
echo "=== Debug: All available identities ==="
security find-identity -v -p codesigning || true
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
# Clean up
rm certificate.p12
@@ -187,7 +138,7 @@ jobs:
SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
run: |
chmod +x ./scripts/codesign_mac_app.sh
./scripts/codesign_mac_app.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app" "ci/mac/eagle0.entitlements"
./scripts/codesign_mac_app.sh "/tmp/eagle0/eagle0MAC/eagle0.app" "ci/mac/eagle0.entitlements"
- name: Submit for Notarization
id: notarize-submit
@@ -198,25 +149,25 @@ jobs:
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
chmod +x ./scripts/notarize_submit.sh
./scripts/notarize_submit.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app" >> $GITHUB_OUTPUT
./scripts/notarize_submit.sh "/tmp/eagle0/eagle0MAC/eagle0.app" >> $GITHUB_OUTPUT
- name: Cleanup Keychain
if: always()
run: |
security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true
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 ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
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-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app.zip
name: signed-mac-app
path: /tmp/eagle0/eagle0MAC/eagle0.app.zip
retention-days: 1
- name: Archive Build Log
@@ -224,11 +175,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: editor_mac.log
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_mac.log
retention-days: 5
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
path: /tmp/eagle0/editor_mac.log
wait-notarization:
needs: build-and-sign
@@ -241,20 +188,20 @@ jobs:
sparse-checkout: scripts
- name: Clean download directory
run: rm -rf ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
run: rm -rf /tmp/eagle0/eagle0MAC
- name: Download signed app
uses: actions/download-artifact@v4
with:
name: signed-mac-app-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
name: signed-mac-app
path: /tmp/eagle0/eagle0MAC
- name: Unzip signed app
run: |
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
cd /tmp/eagle0/eagle0MAC
ditto -x -k eagle0.app.zip .
rm eagle0.app.zip
ls -la ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app/
ls -la /tmp/eagle0/eagle0MAC/eagle0.app/
- name: Wait for Notarization and Staple
env:
@@ -263,23 +210,20 @@ jobs:
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
chmod +x ./scripts/notarize_wait.sh
./scripts/notarize_wait.sh "${{ needs.build-and-sign.outputs.submission_id }}" "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app"
./scripts/notarize_wait.sh "${{ needs.build-and-sign.outputs.submission_id }}" "/tmp/eagle0/eagle0MAC/eagle0.app"
- name: Zip notarized app for artifact
run: |
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
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-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app.zip
name: notarized-mac-app
path: /tmp/eagle0/eagle0MAC/eagle0.app.zip
retention-days: 1
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
deploy:
needs: [build-and-sign, wait-notarization]
@@ -292,17 +236,17 @@ jobs:
fetch-depth: 0 # For version numbering
- name: Clean download directory
run: rm -rf ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
run: rm -rf /tmp/eagle0/eagle0MAC
- name: Download notarized app
uses: actions/download-artifact@v4
with:
name: notarized-mac-app-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
name: notarized-mac-app
path: /tmp/eagle0/eagle0MAC
- name: Unzip notarized app
run: |
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
cd /tmp/eagle0/eagle0MAC
ditto -x -k eagle0.app.zip .
rm eagle0.app.zip
@@ -317,80 +261,14 @@ jobs:
echo "$SPARKLE_EDDSA_PRIVATE_KEY" > "$SPARKLE_PRIVATE_KEY_PATH"
chmod 600 "$SPARKLE_PRIVATE_KEY_PATH"
# Install dmgbuild (creates .DS_Store programmatically, no AppleScript needed)
pip3 install dmgbuild
# Background image for styled DMG
BACKGROUND_PATH="$(pwd)/ci/mac/dmg/background.png"
# Read version from the built app's Info.plist to ensure appcast matches the actual app
APP_PATH="${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app"
BUILD_NUMBER=$(/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$APP_PATH/Contents/Info.plist")
VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP_PATH/Contents/Info.plist")
# Use commit count for automatic incrementing versions (e.g., 1.0.9548)
BUILD_NUMBER=$(git rev-list --count HEAD)
VERSION="1.0.${BUILD_NUMBER}"
bazel run //src/main/go/net/eagle0/build/mac_build_handler:mac_build_handler -- \
"${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app" \
"/tmp/eagle0/eagle0MAC/eagle0.app" \
"$VERSION" \
"$BUILD_NUMBER" \
"$BACKGROUND_PATH" \
"$SPARKLE_PRIVATE_KEY_PATH"
rm "$SPARKLE_PRIVATE_KEY_PATH"
# Export version for notify step
echo "DEPLOYED_VERSION=$VERSION" >> $GITHUB_ENV
- name: Notify clients of update
if: success()
env:
NOTIFY_SECRET: ${{ secrets.EAGLE_NOTIFY_SECRET }}
run: |
# Wait for CDN cache to clear
sleep 60
# Notify via admin server (required=false for normal deploys)
curl -X POST "https://admin.eagle0.net/notify-update?platform=mac&version=$DEPLOYED_VERSION&required=false" \
-H "X-Notify-Secret: $NOTIFY_SECRET" \
--fail --silent --show-error || echo "Warning: Failed to notify clients (non-fatal)"
- name: Delete this run's Mac app artifacts
env:
GH_TOKEN: ${{ github.token }}
run: |
# Delete this run's artifacts (names include run ID to avoid conflicts)
for artifact_name in signed-mac-app-${{ github.run_id }} notarized-mac-app-${{ github.run_id }}; do
echo "Deleting artifact: $artifact_name"
artifact_id=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" \
-q ".artifacts[] | select(.name == \"$artifact_name\") | .id")
if [ -n "$artifact_id" ]; then
echo "Deleting artifact ID: $artifact_id"
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id" || true
fi
done
echo "Cleanup complete"
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
# Cleanup job runs regardless of success/failure to prevent artifact accumulation
cleanup:
needs: [build-and-sign, wait-notarization, deploy]
if: always()
runs-on: ubuntu-latest
steps:
- name: Delete this run's Mac app artifacts
env:
GH_TOKEN: ${{ github.token }}
run: |
# Delete this run's artifacts (names include run ID to avoid conflicts)
for artifact_name in signed-mac-app-${{ github.run_id }} notarized-mac-app-${{ github.run_id }}; do
echo "Deleting artifact: $artifact_name"
artifact_id=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" \
-q ".artifacts[] | select(.name == \"$artifact_name\") | .id")
if [ -n "$artifact_id" ]; then
echo "Deleting artifact ID: $artifact_id"
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id" || true
fi
done
echo "Cleanup complete"
+8 -61
View File
@@ -17,9 +17,6 @@ on:
- "ci/github_actions/build_unity.sh"
- "ci/github_actions/restore_library.sh"
- "ci/github_actions/persist_library.sh"
- "ci/github_actions/upload_addressables.sh"
- "ci/github_actions/ensure_unity_installed.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
- "MODULE.bazel"
- "WORKSPACE"
workflow_dispatch:
@@ -38,69 +35,40 @@ on:
- "ci/github_actions/build_unity.sh"
- "ci/github_actions/restore_library.sh"
- "ci/github_actions/persist_library.sh"
- "ci/github_actions/upload_addressables.sh"
- "ci/github_actions/ensure_unity_installed.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
- "MODULE.bazel"
- "WORKSPACE"
permissions:
contents: read
env:
# Runner-specific build directory to allow parallel builds on multiple runners
EAGLE0_BUILD_DIR: /tmp/eagle0-${{ github.run_id }}
jobs:
windows-unity:
runs-on: [self-hosted, macOS, unity-windows]
steps:
- name: Prune stale PR refs
run: |
# Self-hosted runners persist .git between runs. When a PR is updated,
# old local refs (refs/remotes/pull/*/merge) may point to commits whose
# objects were never fetched or have been pruned. Remove these stale refs
# before checkout to prevent "missing object" errors.
if [ -d ".git" ]; then
echo "Pruning stale PR refs..."
git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
xargs -r git update-ref -d 2>/dev/null || true
fi
- uses: actions/checkout@v4
with:
lfs: false # Fetch LFS after checkout to avoid stale ref issues
lfs: true
clean: true # Remove untracked files from previous builds
- name: Fetch LFS files
run: |
git lfs install
git lfs pull
- name: Ensure Unity version installed
run: ./ci/github_actions/ensure_unity_installed.sh windows
- name: Pull lfs files
run: git lfs pull
- name: Restore Library/
env:
UNITY_CACHE_PLATFORM: windows
run: ./ci/github_actions/restore_library.sh
- name: Build Windows unity
run: ./ci/github_actions/build_unity.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0WIN"
run: ./ci/github_actions/build_unity.sh "/tmp/eagle0/eagle0WIN"
- name: Persist Library/
if: success()
env:
UNITY_CACHE_PLATFORM: windows
run: ./ci/github_actions/persist_library.sh
- name: Upload Addressables to CDN
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: ./ci/github_actions/upload_addressables.sh StandaloneWindows64
- name: Deploy Windows unity
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 -- "${{ env.EAGLE0_BUILD_DIR }}/eagle0WIN" "/tmp/unity_manifest.txt"
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'
@@ -120,35 +88,14 @@ jobs:
echo "Warning: MANIFEST_SIGNING_KEY not set, manifest will be unsigned"
fi
# Update the v2 manifest at installer/v2/eagle0_manifest.txt
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- unity3d-v2 /tmp/unity_manifest.txt $SIGNING_ARGS
# 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
- name: Notify clients of update
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
NOTIFY_SECRET: ${{ secrets.EAGLE_NOTIFY_SECRET }}
run: |
# Wait for CDN cache to clear
sleep 60
# Get version from manifest
VERSION=$(grep "^version=" /tmp/unity_manifest.txt | cut -d= -f2 || date +%Y.%m.%d)
# Notify via admin server (required=false for normal deploys)
curl -X POST "https://admin.eagle0.net/notify-update?platform=windows&version=$VERSION&required=false" \
-H "X-Notify-Secret: $NOTIFY_SECRET" \
--fail --silent --show-error || echo "Warning: Failed to notify clients (non-fatal)"
- name: Archive build log
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: editor_win.log
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_win.log
retention-days: 5
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
path: /tmp/eagle0/editor_win.log
-12
View File
@@ -32,15 +32,3 @@ nogo(
vet = True,
visibility = ["//visibility:public"],
)
# Dependency constraint tests
# These verify architectural boundaries are maintained
sh_test(
name = "build_deps_test",
srcs = ["scripts/check_build_deps.sh"],
args = ["--ci"],
tags = [
"local", # Needs bazel query access
"no-sandbox",
],
)
+5 -21
View File
@@ -7,7 +7,7 @@ NETTY_VERSION = "4.1.110.Final"
SCALAPB_VERSION = "1.0.0-alpha.1"
AWS_SDK_VERSION = "2.41.18"
AWS_SDK_VERSION = "2.28.1"
#
# Core Build Tools
@@ -47,14 +47,14 @@ 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.4",
llvm_version = "20.1.2",
)
# 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.4",
llvm_version = "20.1.2",
)
# Linux x86_64 sysroot for cross-compilation
@@ -67,7 +67,7 @@ llvm.sysroot(
# Cross-compilation toolchain (macOS -> Linux ARM64)
llvm.toolchain(
name = "llvm_toolchain_linux_arm64",
llvm_version = "20.1.4",
llvm_version = "20.1.2",
)
# Linux ARM64 sysroot for cross-compilation
@@ -107,7 +107,6 @@ bazel_dep(name = "gazelle", version = "0.47.0", repo_name = "bazel_gazelle")
go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk")
go_sdk.download(version = "1.23.3")
use_repo(go_sdk, "go_default_sdk")
go_deps = use_extension("@bazel_gazelle//:extensions.bzl", "go_deps")
go_deps.from_file(go_mod = "//:go.mod")
@@ -119,10 +118,8 @@ use_repo(
"com_github_aws_aws_sdk_go_v2_service_s3",
"com_github_golang_jwt_jwt_v5",
"com_github_google_uuid",
"com_github_webview_webview_go",
"org_golang_google_grpc",
"org_golang_google_protobuf",
"org_golang_x_sys",
)
#
@@ -258,7 +255,7 @@ maven.install(
"com.nimbusds:nimbus-jose-jwt:9.37.3",
# Error tracking
"io.sentry:sentry:8.31.0",
"io.sentry:sentry:7.19.0",
],
duplicate_version_warning = "error",
fail_if_repin_required = True,
@@ -339,19 +336,6 @@ http_file(
executable = True,
)
# LLVM MinGW toolchain for Windows cross-compilation from macOS
# This provides a complete toolchain for building Windows executables including
# the MinGW-w64 libraries needed for CGO cross-compilation
LLVM_MINGW_VERSION = "20250305"
http_archive(
name = "llvm_mingw",
build_file = "@//external:BUILD.llvm_mingw",
sha256 = "32c24fc62fc8b9f8a900bf2c730b78b36767688f816f9d21e97a168289ff44e0",
strip_prefix = "llvm-mingw-%s-ucrt-macos-14.4.1-universal" % LLVM_MINGW_VERSION,
urls = ["https://github.com/fathonix/llvm-mingw-arm64ec-macos/releases/download/%s/llvm-mingw-%s-ucrt-macos-14.4.1-universal.tar.xz" % (LLVM_MINGW_VERSION, LLVM_MINGW_VERSION)],
)
#
# Toolchain Registration
#
-470
View File
@@ -611,445 +611,6 @@
]
}
},
"@@aspect_rules_js~//npm:extensions.bzl%pnpm": {
"general": {
"bzlTransitiveDigest": "T22SdPzhLxF1CM+j9RD/Rq03yJ0NDfH2eE6hAbUcOII=",
"usagesDigest": "6rWte4KDbiluq1s7w98bc4+2NjA8w67DKHDj4+DNw/Y=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"pnpm": {
"bzlFile": "@@aspect_rules_js~//npm/private:npm_import.bzl",
"ruleClassName": "npm_import_rule",
"attributes": {
"package": "pnpm",
"version": "8.6.7",
"root_package": "",
"link_workspace": "",
"link_packages": {},
"integrity": "sha512-vRIWpD/L4phf9Bk2o/O2TDR8fFoJnpYrp2TKqTIZF/qZ2/rgL3qKXzHofHgbXsinwMoSEigz28sqk3pQ+yMEQQ==",
"url": "",
"commit": "",
"patch_args": [
"-p0"
],
"patches": [],
"custom_postinstall": "",
"npm_auth": "",
"npm_auth_basic": "",
"npm_auth_username": "",
"npm_auth_password": "",
"lifecycle_hooks": [],
"extra_build_content": "load(\"@aspect_rules_js//js:defs.bzl\", \"js_binary\")\njs_binary(name = \"pnpm\", data = glob([\"package/**\"]), entry_point = \"package/dist/pnpm.cjs\", visibility = [\"//visibility:public\"])",
"generate_bzl_library_targets": false,
"extract_full_archive": true,
"exclude_package_contents": [],
"system_tar": "auto"
}
},
"pnpm__links": {
"bzlFile": "@@aspect_rules_js~//npm/private:npm_import.bzl",
"ruleClassName": "npm_import_links",
"attributes": {
"package": "pnpm",
"version": "8.6.7",
"dev": false,
"root_package": "",
"link_packages": {},
"deps": {},
"transitive_closure": {},
"lifecycle_build_target": false,
"lifecycle_hooks_env": [],
"lifecycle_hooks_execution_requirements": [
"no-sandbox"
],
"lifecycle_hooks_use_default_shell_env": false,
"bins": {},
"package_visibility": [
"//visibility:public"
],
"replace_package": "",
"exclude_package_contents": []
}
}
},
"recordedRepoMappingEntries": [
[
"aspect_bazel_lib~",
"bazel_lib",
"bazel_lib~"
],
[
"aspect_bazel_lib~",
"bazel_skylib",
"bazel_skylib~"
],
[
"aspect_bazel_lib~",
"bazel_tools",
"bazel_tools"
],
[
"aspect_bazel_lib~",
"tar.bzl",
"tar.bzl~"
],
[
"aspect_rules_js~",
"aspect_bazel_lib",
"aspect_bazel_lib~"
],
[
"aspect_rules_js~",
"aspect_rules_js",
"aspect_rules_js~"
],
[
"aspect_rules_js~",
"bazel_features",
"bazel_features~"
],
[
"aspect_rules_js~",
"bazel_skylib",
"bazel_skylib~"
],
[
"aspect_rules_js~",
"bazel_tools",
"bazel_tools"
],
[
"bazel_features~",
"bazel_features_globals",
"bazel_features~~version_extension~bazel_features_globals"
],
[
"bazel_features~",
"bazel_features_version",
"bazel_features~~version_extension~bazel_features_version"
],
[
"bazel_lib~",
"bazel_skylib",
"bazel_skylib~"
],
[
"bazel_lib~",
"bazel_tools",
"bazel_tools"
],
[
"tar.bzl~",
"aspect_bazel_lib",
"aspect_bazel_lib~"
],
[
"tar.bzl~",
"bazel_skylib",
"bazel_skylib~"
],
[
"tar.bzl~",
"tar.bzl",
"tar.bzl~"
]
]
}
},
"@@aspect_rules_ts~//ts:extensions.bzl%ext": {
"general": {
"bzlTransitiveDigest": "h1hftyFCdJgiHD9blfFcMAiEk5ltEoUdNkuHPIkg/hM=",
"usagesDigest": "v0aTa4/gasWF2bvssXYr1bqcYWc3kjV48hcj0z2QVT0=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"npm_typescript": {
"bzlFile": "@@aspect_rules_ts~//ts/private:npm_repositories.bzl",
"ruleClassName": "http_archive_version",
"attributes": {
"bzlmod": true,
"version": "5.8.3",
"integrity": "",
"build_file": "@@aspect_rules_ts~//ts:BUILD.typescript",
"build_file_substitutions": {
"bazel_worker_version": "5.4.2",
"google_protobuf_version": "3.20.1"
},
"urls": [
"https://registry.npmjs.org/typescript/-/typescript-{}.tgz"
]
}
}
},
"recordedRepoMappingEntries": [
[
"aspect_rules_ts~",
"bazel_tools",
"bazel_tools"
]
]
}
},
"@@cel-spec~//:extensions.bzl%non_module_dependencies": {
"general": {
"bzlTransitiveDigest": "/2uyuQa5purSharolRXYOGYMGSGjDeByo6JfQDWsraA=",
"usagesDigest": "2f6juplOpWu+UdD1kVgi773xavnFQ+OcH0PRuQduDxY=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"com_google_googleapis": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"sha256": "bd8e735d881fb829751ecb1a77038dda4a8d274c45490cb9fcf004583ee10571",
"strip_prefix": "googleapis-07c27163ac591955d736f3057b1619ece66f5b99",
"urls": [
"https://github.com/googleapis/googleapis/archive/07c27163ac591955d736f3057b1619ece66f5b99.tar.gz"
]
}
}
},
"recordedRepoMappingEntries": [
[
"cel-spec~",
"bazel_tools",
"bazel_tools"
]
]
}
},
"@@cel-spec~//:googleapis_ext.bzl%googleapis_ext": {
"general": {
"bzlTransitiveDigest": "yun2jmsomFi3bs5bjQWXApBzqQf66zBJ39JEBYigzdc=",
"usagesDigest": "OK8FsLndSl2AbwGM1Npe5NdHR1kDAebcw7Ee+KkekE0=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"com_google_googleapis_imports": {
"bzlFile": "@@cel-spec~~non_module_dependencies~com_google_googleapis//:repository_rules.bzl",
"ruleClassName": "switched_rules",
"attributes": {
"rules": {
"proto_library_with_info": [
"",
""
],
"moved_proto_library": [
"",
""
],
"java_proto_library": [
"",
""
],
"java_grpc_library": [
"",
""
],
"java_gapic_library": [
"",
""
],
"java_gapic_test": [
"",
""
],
"java_gapic_assembly_gradle_pkg": [
"",
""
],
"py_proto_library": [
"",
""
],
"py_grpc_library": [
"",
""
],
"py_gapic_library": [
"",
""
],
"py_test": [
"",
""
],
"py_gapic_assembly_pkg": [
"",
""
],
"py_import": [
"",
""
],
"go_proto_library": [
"",
""
],
"go_library": [
"",
""
],
"go_test": [
"",
""
],
"go_gapic_library": [
"",
""
],
"go_gapic_assembly_pkg": [
"",
""
],
"cc_proto_library": [
"native.cc_proto_library",
""
],
"cc_grpc_library": [
"",
""
],
"cc_gapic_library": [
"",
""
],
"php_proto_library": [
"",
"php_proto_library"
],
"php_grpc_library": [
"",
"php_grpc_library"
],
"php_gapic_library": [
"",
"php_gapic_library"
],
"php_gapic_assembly_pkg": [
"",
"php_gapic_assembly_pkg"
],
"nodejs_gapic_library": [
"",
"typescript_gapic_library"
],
"nodejs_gapic_assembly_pkg": [
"",
"typescript_gapic_assembly_pkg"
],
"ruby_proto_library": [
"",
""
],
"ruby_grpc_library": [
"",
""
],
"ruby_ads_gapic_library": [
"",
""
],
"ruby_cloud_gapic_library": [
"",
""
],
"ruby_gapic_assembly_pkg": [
"",
""
],
"csharp_proto_library": [
"",
""
],
"csharp_grpc_library": [
"",
""
],
"csharp_gapic_library": [
"",
""
],
"csharp_gapic_assembly_pkg": [
"",
""
]
}
}
}
},
"recordedRepoMappingEntries": [
[
"cel-spec~",
"com_google_googleapis",
"cel-spec~~non_module_dependencies~com_google_googleapis"
]
]
}
},
"@@envoy_api~//bazel:repositories.bzl%non_module_deps": {
"general": {
"bzlTransitiveDigest": "6TqmRfVELxZJRPQYuJpC4JBX4QvdrHqTDOUJGOGODSo=",
"usagesDigest": "IivjlawPvqhHPUJ3c6dLiPsH22mn/g/dJtlmv3zdimM=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"prometheus_metrics_model": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"urls": [
"https://github.com/prometheus/client_model/archive/v0.6.1.tar.gz"
],
"sha256": "b9b690bc35d80061f255faa7df7621eae39fe157179ccd78ff6409c3b004f05e",
"strip_prefix": "client_model-0.6.1",
"build_file_content": "\nload(\"@envoy_api//bazel:api_build_system.bzl\", \"api_cc_py_proto_library\")\nload(\"@io_bazel_rules_go//proto:def.bzl\", \"go_proto_library\")\n\napi_cc_py_proto_library(\n name = \"client_model\",\n srcs = [\n \"io/prometheus/client/metrics.proto\",\n ],\n visibility = [\"//visibility:public\"],\n)\n\ngo_proto_library(\n name = \"client_model_go_proto\",\n importpath = \"github.com/prometheus/client_model/go\",\n proto = \":client_model\",\n visibility = [\"//visibility:public\"],\n)\n"
}
},
"com_github_bufbuild_buf": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"urls": [
"https://github.com/bufbuild/buf/releases/download/v1.49.0/buf-Linux-x86_64.tar.gz"
],
"sha256": "ee8da9748249f7946d79191e36469ce7bc3b8ba80019bff1fa4289a44cbc23bf",
"strip_prefix": "buf",
"build_file_content": "\npackage(\n default_visibility = [\"//visibility:public\"],\n)\n\nfilegroup(\n name = \"buf\",\n srcs = [\n \"@com_github_bufbuild_buf//:bin/buf\",\n ],\n tags = [\"manual\"], # buf is downloaded as a linux binary; tagged manual to prevent build for non-linux users\n)\n"
}
},
"envoy_toolshed": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"urls": [
"https://github.com/envoyproxy/toolshed/archive/bazel-v0.2.2.tar.gz"
],
"sha256": "443fe177aba0cef8c17b7a48905c925c67b09005b10dd70ff12cd9f729a72d51",
"strip_prefix": "toolshed-bazel-v0.2.2/bazel"
}
}
},
"recordedRepoMappingEntries": [
[
"envoy_api~",
"bazel_tools",
"bazel_tools"
],
[
"envoy_api~",
"envoy_api",
"envoy_api~"
]
]
}
},
"@@googleapis~//:extensions.bzl%switched_rules": {
"general": {
"bzlTransitiveDigest": "vG6fuTzXD8MMvHWZEQud0MMH7eoC4GXY0va7VrFFh04=",
@@ -1218,37 +779,6 @@
"recordedRepoMappingEntries": []
}
},
"@@pybind11_bazel~//:internal_configure.bzl%internal_configure_extension": {
"general": {
"bzlTransitiveDigest": "CyAKLVVonohnkTSqg9II/HA7M49sOlnMkgMHL3CmDuc=",
"usagesDigest": "mFrTHX5eCiNU/OIIGVHH3cOILY9Zmjqk8RQYv8o6Thk=",
"recordedFileInputs": {
"@@pybind11_bazel~//MODULE.bazel": "e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34"
},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"pybind11": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"build_file": "@@pybind11_bazel~//:pybind11-BUILD.bazel",
"strip_prefix": "pybind11-2.12.0",
"urls": [
"https://github.com/pybind/pybind11/archive/v2.12.0.zip"
]
}
}
},
"recordedRepoMappingEntries": [
[
"pybind11_bazel~",
"bazel_tools",
"bazel_tools"
]
]
}
},
"@@rules_foreign_cc~//foreign_cc:extensions.bzl%tools": {
"general": {
"bzlTransitiveDigest": "a7qnESofmIRYId6wwGNPJ9kvExU80KrkxL281P3+lBE=",
-21
View File
@@ -1,19 +1,6 @@
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load", "oci_push")
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
#
# Deployment artifacts (tools needed on the host, not in containers)
#
pkg_tar(
name = "warmup_tar",
srcs = ["//src/main/go/net/eagle0/warmup:warmup_linux_amd64"],
package_dir = "bin",
remap_paths = {
"/warmup_linux_amd64": "/warmup",
},
)
#
# Shared utilities layer (busybox for nc, wget, etc.)
#
@@ -330,13 +317,6 @@ pkg_tar(
package_dir = "/app",
)
# Package the attributions.json for the credits page
pkg_tar(
name = "auth_attributions_layer",
srcs = ["//src/main/resources/net/eagle0:attributions"],
package_dir = "/app",
)
oci_image(
name = "auth_server_image",
base = "@alpine_linux_linux_amd64",
@@ -348,7 +328,6 @@ oci_image(
tars = [
":busybox_layer",
":auth_binary_layer",
":auth_attributions_layer",
],
workdir = "/app",
)
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
/bin/mkdir -p win_output
/usr/bin/dotnet publish src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller.sln -o win_output
SHA=`sha256sum /tmp/EagleInstaller.exe | awk '{print $1 }'`
ZIP_FILE="updater__$SHA.zip"
/usr/bin/zip win_output/$ZIP_FILE win_output/EagleInstaller.exe
rm win_output/EagleInstaller.exe
rm win_output/EagleInstaller.pdb
DATE=`date +"%Y-%m-%d %T"`
cat > win_output/updater.html <<-EOF
<html>
<head>
<title>Download Eagle Updater</title>
</head>
<body>
<a href="http://eagle0.net/assets/$ZIP_FILE">$ZIP_FILE</a> (updated $DATE)
</body>
</html>
EOF
SSH_KEY_FILE=$1
SSH_USER_NAME=$2
/usr/bin/rsync -r --copy-links -e "/usr/bin/ssh -i $SSH_KEY_FILE -p 9022" win_output/ $SSH_USER_NAME@eagle0.net:/www/assets/
-102
View File
@@ -1,102 +0,0 @@
#!/usr/bin/env bash
# Archive and export iOS app for App Store / TestFlight
set -euxo pipefail
# Ensure xcodebuild uses Xcode.app, not Command Line Tools
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
XCODE_PROJECT_PATH=${1:?Usage: archive_ios.sh <xcode_project_path> <output_path> <team_id> <profile_uuid>}
OUTPUT_PATH=${2:?Missing output path}
TEAM_ID=${3:?Missing team ID}
PROFILE_UUID=${4:?Missing provisioning profile UUID}
ARCHIVE_PATH="$OUTPUT_PATH/eagle0.xcarchive"
EXPORT_PATH="$OUTPUT_PATH"
echo "Archiving iOS app..."
echo " Xcode project: $XCODE_PROJECT_PATH"
echo " Archive path: $ARCHIVE_PATH"
echo " Team ID: $TEAM_ID"
mkdir -p "$OUTPUT_PATH"
# Find the .xcodeproj file
XCODEPROJ=$(find "$XCODE_PROJECT_PATH" -name "*.xcodeproj" -maxdepth 1 | head -1)
if [ -z "$XCODEPROJ" ]; then
echo "Error: No .xcodeproj found in $XCODE_PROJECT_PATH"
exit 1
fi
echo "Found Xcode project: $XCODEPROJ"
# Unity always generates "Unity-iPhone" as the main app scheme
SCHEME="Unity-iPhone"
echo "Using scheme: $SCHEME"
# Archive without signing - we'll sign during export
# This avoids issues with provisioning profiles on framework targets
xcodebuild archive \
-project "$XCODEPROJ" \
-scheme "$SCHEME" \
-archivePath "$ARCHIVE_PATH" \
-destination "generic/platform=iOS" \
CODE_SIGN_IDENTITY="-" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO
echo "Archive complete: $ARCHIVE_PATH"
# Create export options plist
# Signing happens here, not during archive
EXPORT_OPTIONS_PLIST="$OUTPUT_PATH/ExportOptions.plist"
cat > "$EXPORT_OPTIONS_PLIST" << EOF
<?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>
<key>method</key>
<string>app-store-connect</string>
<key>teamID</key>
<string>$TEAM_ID</string>
<key>uploadSymbols</key>
<true/>
<key>signingStyle</key>
<string>manual</string>
<key>signingCertificate</key>
<string>Apple Distribution: Daniel Crosby (UWJ88DX8WQ)</string>
<key>provisioningProfiles</key>
<dict>
<key>net.eagle0.eagle</key>
<string>$PROFILE_UUID</string>
</dict>
</dict>
</plist>
EOF
echo "Exporting IPA..."
# Debug: List available keychains and signing identities
echo "=== Debug: Available keychains ==="
security list-keychains -d user
echo "=== Debug: Available signing identities ==="
security find-identity -v -p codesigning
echo "=== Debug: Export options plist ==="
cat "$EXPORT_OPTIONS_PLIST"
echo "=== End debug ==="
# Export IPA (removed -allowProvisioningUpdates as we're using manual signing)
xcodebuild -exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportPath "$EXPORT_PATH" \
-exportOptionsPlist "$EXPORT_OPTIONS_PLIST"
# Find and rename the IPA to a consistent name
IPA_FILE=$(find "$EXPORT_PATH" -name "*.ipa" | head -1)
if [ -n "$IPA_FILE" ] && [ "$IPA_FILE" != "$EXPORT_PATH/eagle0.ipa" ]; then
mv "$IPA_FILE" "$EXPORT_PATH/eagle0.ipa"
fi
echo "Export complete: $EXPORT_PATH/eagle0.ipa"
ls -la "$EXPORT_PATH"
@@ -1,49 +0,0 @@
#!/usr/bin/env bash
# Build iOS Addressables only (no player build)
# This switches Unity to iOS target and builds addressables for CDN upload
set -euxo pipefail
# Read Unity version from project file
UNITY_VERSION=$(grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //')
# Use runner-specific build directory if EAGLE0_BUILD_DIR is set, otherwise default
BUILD_BASE="${EAGLE0_BUILD_DIR:-/tmp/eagle0}"
WORKSPACE=$(pwd)
echo "Building protos"
./scripts/build_protos.sh
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
LOG_PATH=${1:-"${BUILD_BASE}/editor_ios_addressables.log"}
echo "Building iOS Addressables"
mkdir -p "$(dirname "$LOG_PATH")"
# Build Addressables for iOS target
# Uses BuildiOSAddressables which explicitly switches build target
# Capture exit code to show log on failure
set +e
${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
-nographics \
-batchmode \
-quit \
-executeMethod BuildScript.BuildiOSAddressables \
-logFile "$LOG_PATH" \
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
UNITY_EXIT_CODE=$?
set -e
if [ $UNITY_EXIT_CODE -ne 0 ]; then
echo ""
echo "Unity build failed with exit code $UNITY_EXIT_CODE"
echo "=== Unity Editor Log (last 200 lines) ==="
tail -200 "$LOG_PATH" || echo "Could not read log file at $LOG_PATH"
echo "=== End of Unity Editor Log ==="
exit $UNITY_EXIT_CODE
fi
echo "iOS Addressables build complete"
echo "Bundles should be in: $WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0/ServerData/iOS/"
+2 -18
View File
@@ -2,8 +2,7 @@
set -euxo pipefail
# Read Unity version from project file
UNITY_VERSION=$(grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //')
. ./ci/unity_version.sh
WORKSPACE=$(pwd)
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
@@ -16,25 +15,10 @@ echo "Cleaning up $BUILD_DIR"
/bin/rm -rf "$BUILD_DIR"
/bin/mkdir -p "$BUILD_DIR"
# Use custom build script that builds Addressables before the player
# Capture exit code to show log on failure
set +e
${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
-nographics \
-batchmode \
-quit \
-executeMethod BuildScript.BuildMacPlayer \
-buildPath "$BUILD_DIR/eagle0.app" \
-buildOSXUniversalPlayer "$BUILD_DIR/eagle0.app" \
-logFile "$LOG_PATH" \
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
UNITY_EXIT_CODE=$?
set -e
if [ $UNITY_EXIT_CODE -ne 0 ]; then
echo ""
echo "Unity build failed with exit code $UNITY_EXIT_CODE"
echo "=== Unity Editor Log (last 200 lines) ==="
tail -200 "$LOG_PATH" || echo "Could not read log file at $LOG_PATH"
echo "=== End of Unity Editor Log ==="
exit $UNITY_EXIT_CODE
fi
+2 -3
View File
@@ -2,8 +2,7 @@
set -euxo pipefail
# Use runner-specific build directory if EAGLE0_BUILD_DIR is set, otherwise default
BUILD_BASE="${EAGLE0_BUILD_DIR:-/tmp/eagle0}"
LOG_PATH=""
COMMIT=$(/usr/bin/git rev-parse --short HEAD)
@@ -13,7 +12,7 @@ COMMIT=$(/usr/bin/git rev-parse --short HEAD)
git log -3
/bin/echo "build Windows"
LOG_PATH="${BUILD_BASE}/editor_win.log"
LOG_PATH="/tmp/eagle0/editor_win.log"
BUILD_DIR=$1
./ci/github_actions/build_windows.sh $BUILD_DIR $LOG_PATH
-58
View File
@@ -1,58 +0,0 @@
#!/usr/bin/env bash
# Build iOS Unity player (generates Xcode project)
set -euxo pipefail
# Read Unity version from project file
UNITY_VERSION=$(grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //')
# Use runner-specific build directory if EAGLE0_BUILD_DIR is set, otherwise default
BUILD_BASE="${EAGLE0_BUILD_DIR:-/tmp/eagle0}"
WORKSPACE=$(pwd)
BUILD_PATH=${1:-"${BUILD_BASE}/eagle0iOS"}
LOG_PATH="${BUILD_BASE}/editor_ios.log"
# Generate unique build number from git commit count
# This ensures each build has a unique number for TestFlight
BUILD_NUMBER=$(git rev-list --count HEAD)
echo "Build number (git commit count): $BUILD_NUMBER"
echo "Building protos"
./scripts/build_protos.sh
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
echo "Building iOS Unity player to: $BUILD_PATH"
mkdir -p "$(dirname "$LOG_PATH")"
mkdir -p "$BUILD_PATH"
# Build iOS player - this generates an Xcode project
# Capture exit code to show log on failure
set +e
${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
-nographics \
-batchmode \
-quit \
-executeMethod BuildScript.BuildiOSPlayer \
-buildPath "$BUILD_PATH" \
-buildNumber "$BUILD_NUMBER" \
-logFile "$LOG_PATH" \
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
UNITY_EXIT_CODE=$?
set -e
if [ $UNITY_EXIT_CODE -ne 0 ]; then
echo ""
echo "Unity build failed with exit code $UNITY_EXIT_CODE"
echo "=== Unity Editor Log (last 200 lines) ==="
tail -200 "$LOG_PATH" || echo "Could not read log file at $LOG_PATH"
echo "=== End of Unity Editor Log ==="
exit $UNITY_EXIT_CODE
fi
echo "iOS Unity build complete"
echo "Xcode project generated at: $BUILD_PATH"
ls -la "$BUILD_PATH"
+1 -4
View File
@@ -2,9 +2,6 @@
set -euxo pipefail
# Use runner-specific build directory if EAGLE0_BUILD_DIR is set, otherwise default
BUILD_BASE="${EAGLE0_BUILD_DIR:-/tmp/eagle0}"
COMMIT=$(/usr/bin/git rev-parse --short HEAD)
/bin/echo "build protos"
@@ -16,7 +13,7 @@ COMMIT=$(/usr/bin/git rev-parse --short HEAD)
git log -3
/bin/echo "build Mac"
LOG_PATH="${BUILD_BASE}/editor_mac.log"
LOG_PATH="/tmp/eagle0/editor_mac.log"
BUILD_DIR=$1
./ci/github_actions/build_mac.sh "$BUILD_DIR" "$LOG_PATH"
+2 -18
View File
@@ -2,8 +2,7 @@
set -euxo pipefail
# Read Unity version from project file
UNITY_VERSION=$(grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //')
. ./ci/unity_version.sh
WORKSPACE=`pwd`
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
@@ -16,25 +15,10 @@ echo "Cleaning up $1"
/bin/rm -rf $1
/bin/mkdir -p $1
# Use custom build script that builds Addressables before the player
# Capture exit code to show log on failure
set +e
${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
-nographics \
-batchmode \
-quit \
-executeMethod BuildScript.BuildWindowsPlayer \
-buildPath "$BUILD_DIR/eagle0.exe" \
-buildWindows64Player $BUILD_DIR/eagle0.exe \
-logFile $LOG_PATH \
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
UNITY_EXIT_CODE=$?
set -e
if [ $UNITY_EXIT_CODE -ne 0 ]; then
echo ""
echo "Unity build failed with exit code $UNITY_EXIT_CODE"
echo "=== Unity Editor Log (last 200 lines) ==="
tail -200 "$LOG_PATH" || echo "Could not read log file at $LOG_PATH"
echo "=== End of Unity Editor Log ==="
exit $UNITY_EXIT_CODE
fi
-233
View File
@@ -1,233 +0,0 @@
#!/usr/bin/env bash
# Ensures the required Unity version is installed via Unity Hub.
# Usage: ./ensure_unity_installed.sh [PLATFORM]
#
# PLATFORM can be: mac, windows, ios, or all (default: all)
#
# This script:
# 1. Reads the required version from ProjectSettings/ProjectVersion.txt
# 2. Checks if it's already installed
# 3. If not, attempts to install it via Unity Hub CLI with appropriate modules
#
# Note: Unity Hub CLI installation may require:
# - Unity Hub to be installed
# - User to be logged in to Unity Hub (for some versions)
# - Appropriate licenses
set -euo pipefail
# Read Unity version directly from the project file (maintained by Unity itself)
PROJECT_VERSION_FILE="src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
UNITY_VERSION=$(grep "m_EditorVersion:" "$PROJECT_VERSION_FILE" | head -1 | sed 's/m_EditorVersion: //')
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
UNITY_HUB_CLI="/Applications/Unity Hub.app/Contents/MacOS/Unity Hub"
LOCK_FILE="/tmp/unity_install.lock"
LOCK_TIMEOUT=1800 # 30 minutes max wait for another installation
PLATFORM="${1:-all}"
echo "Required Unity version: ${UNITY_VERSION}"
echo "Platform: ${PLATFORM}"
# Check if Unity is installed and has required modules
check_modules_installed() {
local unity_path="${UNITY_INSTALL_PATH}/${UNITY_VERSION}"
if [ ! -d "$unity_path" ]; then
return 1
fi
case "${PLATFORM}" in
ios)
# iOS module installs to PlaybackEngines/iOSSupport
if [ ! -d "${unity_path}/PlaybackEngines/iOSSupport" ]; then
echo "✗ iOS module not installed for Unity ${UNITY_VERSION}"
return 1
fi
;;
mac)
# Mac IL2CPP module
if [ ! -d "${unity_path}/PlaybackEngines/MacStandaloneSupport/Variations/macos_development_il2cpp" ] && \
[ ! -d "${unity_path}/PlaybackEngines/MacStandaloneSupport" ]; then
echo "✗ Mac IL2CPP module not installed for Unity ${UNITY_VERSION}"
return 1
fi
;;
windows)
# Windows mono module
if [ ! -d "${unity_path}/PlaybackEngines/WindowsStandaloneSupport" ]; then
echo "✗ Windows module not installed for Unity ${UNITY_VERSION}"
return 1
fi
;;
all)
# Check all required modules
local missing=0
if [ ! -d "${unity_path}/PlaybackEngines/iOSSupport" ]; then
echo "✗ iOS module missing"
missing=1
fi
if [ ! -d "${unity_path}/PlaybackEngines/MacStandaloneSupport" ]; then
echo "✗ Mac module missing"
missing=1
fi
if [ ! -d "${unity_path}/PlaybackEngines/WindowsStandaloneSupport" ]; then
echo "✗ Windows module missing"
missing=1
fi
if [ $missing -eq 1 ]; then
return 1
fi
;;
esac
return 0
}
# Check if already installed with required modules
if check_modules_installed; then
echo "✓ Unity ${UNITY_VERSION} is already installed with ${PLATFORM} support"
exit 0
fi
if [ ! -d "${UNITY_INSTALL_PATH}/${UNITY_VERSION}" ]; then
echo "✗ Unity ${UNITY_VERSION} not found at ${UNITY_INSTALL_PATH}/${UNITY_VERSION}"
fi
# Check if Unity Hub CLI is available
if [ ! -f "${UNITY_HUB_CLI}" ]; then
echo ""
echo "Unity Hub CLI not found at ${UNITY_HUB_CLI}"
echo ""
echo "To install Unity ${UNITY_VERSION} manually:"
echo " 1. Open Unity Hub"
echo " 2. Go to Installs -> Install Editor"
echo " 3. Select version ${UNITY_VERSION}"
echo " 4. Add modules based on platform: mac-il2cpp, windows-mono, ios"
exit 1
fi
# Acquire lock to prevent concurrent installations
acquire_lock() {
local waited=0
while ! mkdir "${LOCK_FILE}" 2>/dev/null; do
if [ $waited -ge $LOCK_TIMEOUT ]; then
echo "ERROR: Timed out waiting for Unity installation lock after ${LOCK_TIMEOUT}s"
echo "Another installation may be stuck. Remove ${LOCK_FILE} manually if needed."
exit 1
fi
echo "Another Unity installation is in progress, waiting... (${waited}s)"
sleep 10
waited=$((waited + 10))
done
# Store PID for debugging
echo $$ > "${LOCK_FILE}/pid"
trap release_lock EXIT
}
release_lock() {
rm -rf "${LOCK_FILE}" 2>/dev/null || true
}
# Re-check after acquiring lock (another process may have installed it)
acquire_lock
if check_modules_installed; then
echo "✓ Unity ${UNITY_VERSION} with ${PLATFORM} support was installed while waiting for lock"
exit 0
fi
echo ""
# Determine modules needed for this platform
get_modules_for_platform() {
case "${PLATFORM}" in
mac)
echo "mac-il2cpp"
;;
windows)
echo "windows-mono"
;;
ios)
echo "ios"
;;
all)
echo "mac-il2cpp windows-mono ios"
;;
*)
echo "Unknown platform: ${PLATFORM}" >&2
echo "Valid platforms: mac, windows, ios, all" >&2
exit 1
;;
esac
}
MODULES=$(get_modules_for_platform)
# Check if editor is already installed (just missing modules)
if [ -d "${UNITY_INSTALL_PATH}/${UNITY_VERSION}" ]; then
echo "Unity ${UNITY_VERSION} is installed but missing modules. Adding modules..."
echo ""
# Use install-modules to add modules to existing installation
INSTALL_CMD=("${UNITY_HUB_CLI}" -- --headless install-modules --version "${UNITY_VERSION}")
for mod in $MODULES; do
INSTALL_CMD+=(--module "$mod")
done
else
echo "Attempting to install Unity ${UNITY_VERSION} via Unity Hub CLI..."
echo ""
# Use install to install editor with modules
INSTALL_CMD=("${UNITY_HUB_CLI}" -- --headless install --version "${UNITY_VERSION}")
for mod in $MODULES; do
INSTALL_CMD+=(--module "$mod")
done
fi
echo "Running: ${INSTALL_CMD[*]}"
echo ""
# Capture output to check for "already installed" messages
OUTPUT=$("${INSTALL_CMD[@]}" 2>&1) || true
EXIT_CODE=$?
echo "$OUTPUT"
# Check if modules are already installed (Unity Hub returns error but modules are present)
if echo "$OUTPUT" | grep -q "already installed\|No modules found to install"; then
echo ""
echo "✓ Modules already installed"
elif [ $EXIT_CODE -eq 0 ]; then
echo ""
echo "Unity Hub CLI command completed"
else
echo ""
echo "Unity Hub CLI command failed (exit code: ${EXIT_CODE})"
echo ""
echo "This may happen if:"
echo " - The Unity version is not available for download"
echo " - You need to log in to Unity Hub first"
echo " - Unity Hub requires a GUI interaction"
echo ""
echo "To install manually:"
echo " 1. Open Unity Hub"
echo " 2. Go to Installs -> Install Editor"
echo " 3. Select version ${UNITY_VERSION}"
echo " 4. Add modules: ${PLATFORM}"
exit 1
fi
# Verify installation
echo ""
if [ -d "${UNITY_INSTALL_PATH}/${UNITY_VERSION}" ]; then
echo "✓ Verified: Unity ${UNITY_VERSION} is now installed"
exit 0
else
echo "✗ Unity ${UNITY_VERSION} installation could not be verified"
echo " Expected path: ${UNITY_INSTALL_PATH}/${UNITY_VERSION}"
echo ""
echo "The installation may still be in progress, or may require manual intervention."
exit 1
fi
-44
View File
@@ -1,44 +0,0 @@
#!/usr/bin/env bash
# Upload Addressables bundles to DigitalOcean Spaces
# Usage: ./upload_addressables.sh <build_target>
# Example: ./upload_addressables.sh StandaloneOSX
#
# Required environment variables:
# ACCESS_KEY_ID - DigitalOcean Spaces access key (same as other deploys)
# SECRET_KEY - DigitalOcean Spaces secret key (same as other deploys)
set -euxo pipefail
BUILD_TARGET=$1
WORKSPACE=$(pwd)
UNITY_PROJECT="$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
SERVER_DATA="$UNITY_PROJECT/ServerData/$BUILD_TARGET"
# DigitalOcean Spaces configuration (same region as other eagle0 buckets)
DO_ENDPOINT="https://sfo3.digitaloceanspaces.com"
DO_BUCKET="eagle0-assets"
if [ ! -d "$SERVER_DATA" ]; then
echo "No Addressables bundles found at $SERVER_DATA"
echo "Skipping upload (this is expected if Addressables are bundled locally)"
exit 0
fi
echo "Uploading Addressables bundles from $SERVER_DATA"
echo "Target: s3://$DO_BUCKET/addressables/$BUILD_TARGET/"
# Configure AWS CLI for DigitalOcean Spaces
export AWS_ACCESS_KEY_ID="$ACCESS_KEY_ID"
export AWS_SECRET_ACCESS_KEY="$SECRET_KEY"
# Sync bundles to Spaces
# --delete removes files in destination that don't exist in source
# --acl public-read makes files publicly accessible
aws s3 sync "$SERVER_DATA" "s3://$DO_BUCKET/addressables/$BUILD_TARGET/" \
--endpoint-url "$DO_ENDPOINT" \
--acl public-read \
--delete
echo "Addressables upload complete"
echo "Files available at: https://assets.eagle0.net/addressables/$BUILD_TARGET/"
-48
View File
@@ -1,48 +0,0 @@
#!/usr/bin/env bash
# Upload IPA to TestFlight using Apple ID credentials (same as Mac notarization)
set -euxo pipefail
IPA_PATH=${1:?Usage: upload_testflight.sh <ipa_path>}
if [ ! -f "$IPA_PATH" ]; then
echo "Error: IPA file not found: $IPA_PATH"
exit 1
fi
echo "Uploading to TestFlight: $IPA_PATH"
# Uses same credentials as Mac notarization:
# - APPLE_ID: Your Apple ID email
# - APP_SPECIFIC_PASSWORD: App-specific password from appleid.apple.com
if [ -z "${APPLE_ID:-}" ]; then
echo "Error: APPLE_ID environment variable not set"
exit 1
fi
if [ -z "${APP_SPECIFIC_PASSWORD:-}" ]; then
echo "Error: APP_SPECIFIC_PASSWORD environment variable not set"
exit 1
fi
echo "Uploading with xcrun altool..."
# Capture output to check for errors (altool may return 0 even on failure)
OUTPUT=$(xcrun altool --upload-app \
--type ios \
--file "$IPA_PATH" \
--username "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" 2>&1) || true
echo "$OUTPUT"
# Check for error indicators in output
if echo "$OUTPUT" | grep -q "ERROR:"; then
echo "ERROR: Upload failed. See error messages above."
exit 1
fi
echo "Upload complete! Check App Store Connect for processing status."
echo "The build should appear in TestFlight within 15-30 minutes after processing."
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
UNITY_VERSION='6000.3.0f1'
+4 -11
View File
@@ -18,6 +18,8 @@ services:
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}"
- "--auth-service-url"
@@ -26,8 +28,6 @@ services:
- "40032:40032"
environment:
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
GEMINI_API_KEY: "${GEMINI_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:-}"
@@ -67,6 +67,8 @@ services:
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}"
- "--auth-service-url"
@@ -75,8 +77,6 @@ services:
- "40034:40032" # Different host port for staging
environment:
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
GEMINI_API_KEY: "${GEMINI_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:-}"
@@ -208,13 +208,6 @@ services:
- "${JFR_SIDECAR_ADDR:-jfr-sidecar:8081}" # Can be switched for blue-green
- "--http-port"
- "8080"
environment:
# Secret for CI to authenticate client update notifications
NOTIFY_SECRET: "${NOTIFY_SECRET:-}"
# S3/Spaces credentials for What's New storage
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:-}"
# No external port - accessed via nginx at admin.eagle0.net
depends_on:
- auth
-235
View File
@@ -1,235 +0,0 @@
# Adding New Quest Types
This document describes how to add new quest types to Eagle. Quests are tasks that unaffiliated heroes want factions to complete before they'll join.
## Deployment Strategy: Three-PR Approach
When adding new quest types, use a three-PR strategy to ensure clients never see quests they don't understand:
1. **PR 1 - Types Only (Server)**: Add proto definitions, Scala types, converters, and all handling logic (fulfillment, failure, LLM prompts). Do NOT generate the quests yet.
2. **PR 2 - Client Handling**: Add client-side display code (C#/Unity) that can render the new quest type.
3. **PR 3 - Quest Generation (Server)**: Add the actual quest creation logic to `QuestCreationUtils.scala`.
This order ensures that when the server starts generating the new quest type, all clients already know how to display it.
## Files to Modify
### Server-Side (Scala)
#### 1. Proto Definition
**File:** `src/main/protobuf/net/eagle0/eagle/common/unaffiliated_hero_quest.proto`
Add a new message type for your quest and add it to the `QuestDetails` oneof:
```protobuf
message MyNewQuest {
int32 some_field = 1;
string another_field = 2;
}
// In the QuestDetails message, add to the oneof:
oneof sealed_value {
// ... existing quests ...
MyNewQuest my_new_quest = XX; // Use next available field number
}
```
#### 2. Scala Case Class
**File:** `src/main/scala/net/eagle0/eagle/model/state/quest/Quest.scala`
Add a case class (or case object for quests with no parameters):
```scala
case class MyNewQuest(
someField: Int,
anotherField: String
) extends Quest
// For quests with multi-part completion, extend ComponentQuest:
case class MyComponentQuest(
override val componentCount: Int,
override val componentsFulfilled: Int,
targetValue: Int
) extends ComponentQuest {
override def withComponentsFulfilled(componentsFulfilled: Int): Quest =
this.copy(componentsFulfilled = componentsFulfilled)
}
```
#### 3. Proto Converter
**File:** `src/main/scala/net/eagle0/eagle/model/proto_converters/QuestConverter.scala`
Add conversions in both `toProto` and `fromProto` methods:
```scala
// In toProto:
case q: MyNewQuest =>
QuestProto(details = MyNewQuestProto(q.someField, q.anotherField))
// In fromProto:
case MyNewQuestProto(someField, anotherField, _) =>
MyNewQuest(someField, anotherField)
```
#### 4. Quest Fulfillment Check
**File:** `src/main/scala/net/eagle0/eagle/library/actions/impl/action/CheckForFulfilledQuestsAction.scala`
Add a case to `didFulfillQuest` that returns `true` when the quest conditions are met:
```scala
case MyNewQuest(someField, anotherField) =>
// Return true if quest is fulfilled
someConditionIsMet(province, someField)
```
For `ComponentQuest` subclasses, the base case `case q: ComponentQuest => q.componentsFulfilled >= q.componentCount` handles fulfillment automatically.
#### 5. Quest Failure Check
**File:** `src/main/scala/net/eagle0/eagle/library/actions/impl/action/CheckForFailedQuestsAction.scala`
Add a case to `isQuestFailed` if your quest can fail (e.g., if a required faction is destroyed):
```scala
case MyNewQuest(someField, _) =>
// Return true if quest can no longer be completed
!factionWithId(someField).exists(_.isActive)
```
Many quests use the default case that returns `false` (quest never fails automatically).
#### 6. LLM Prompt Generators
**File:** `src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators/DivineMessagePromptGenerator.scala`
Add a case to `describeQuest` for when a soothsayer reveals the quest:
```scala
case MyNewQuest(someField, anotherField) =>
TextGenerationSuccess(
s"$divinedHeroName wants ${faction.name} to do something with $someField."
)
```
**File:** `src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators/QuestEndedGeneratorUtilities.scala`
Add a case to `pastTenseQuestDescription` for quest fulfilled/failed narratives:
```scala
case MyNewQuest(someField, anotherField) =>
for {
unaffiliatedHeroName <- unaffiliatedHeroNameResult
} yield s"$unaffiliatedHeroName wanted ${faction.name} to do something with $someField."
```
#### 7. Quest Creation (PR 3 only)
**File:** `src/main/scala/net/eagle0/eagle/library/util/quest_creation/QuestCreationUtils.scala`
Add a quest creator function and register it in `availableQuests`:
```scala
private def myNewQuests(
province: ProvinceT,
allProvinces: Vector[ProvinceT],
@unused factions: Vector[FactionT],
@unused battalions: Vector[BattalionT],
functionalRandom: FunctionalRandom
): RandomState[Vector[Quest]] = {
// Return empty if quest shouldn't be available
if (!someCondition) RandomState(Vector(), functionalRandom)
else {
functionalRandom.nextIntInclusive(minValue, maxValue).map { value =>
Vector(MyNewQuest(value, "something"))
}
}
}
// Add to availableQuests list:
def availableQuests(...) = functionalRandom.nextFlatMap(
Vector[QuestCreator](
// ... existing creators ...
myNewQuests
)
) { ... }
```
#### 8. Optional: Quest Command Selectors (AI auto-completion)
**Directory:** `src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/quest_command_selectors/`
If you want AI players to automatically work toward completing your quest, create a command chooser.
### Client-Side (C#/Unity)
#### 1. Quest Type Display Name
**File:** `src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/DisplayNames.cs`
Add a case to `QuestTypeString`:
```csharp
case SealedValueOneofCase.MyNewQuest: return "My New Quest";
```
#### 2. Quest Description String
**File:** `src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/Table Rows/UnaffiliatedHeroRowController.cs`
Add a case to `ShortQuestString` for displaying the quest in the UI:
```csharp
case SealedValueOneofCase.MyNewQuest: {
var details = quest.Details.MyNewQuest;
return $"Do something with {details.SomeField}";
}
```
For quests involving heroes (where you want dynamic name updates), add a case to `SetQuestText` instead.
### Build Files
After making changes, run:
```bash
bazel run gazelle
```
This updates BUILD.bazel files with any new dependencies.
## Quest Type Categories
### Simple Quests
Quests with fixed completion conditions (e.g., `AllianceQuest`, `SuppressRiotByForceQuest`).
### Component Quests
Quests with multi-part completion that track progress via `componentsFulfilled` / `componentCount` (e.g., `AlmsToProvinceQuest`, `DevelopProvincesQuest`). Extend `ComponentQuest` and implement `withComponentsFulfilled`.
### Action Quests
Quests completed by specific player actions rather than state checks. Return `false` in `didFulfillQuest` and handle completion in the relevant command handler (e.g., `ExecutePrisonerQuest` is fulfilled in prisoner management command).
## Testing
1. Build all modified targets:
```bash
bazel build //src/main/scala/net/eagle0/eagle/model/state/quest:quest
bazel build //src/main/scala/net/eagle0/eagle/model/proto_converters:quest_converter
```
2. Run tests:
```bash
bazel test //src/test/scala/...
```
3. Verify proto/Scala parity:
```bash
bazel test //src/test/scala/net/eagle0/eagle/model/action_result/types:action_result_type_parity_test
```
## Current Quest Types (30 total)
- **Diplomacy**: AllianceQuest, TruceWithFactionQuest, TruceCountQuest, DefeatFactionQuest
- **Development**: ImproveAgricultureQuest, ImproveEconomyQuest, ImproveInfrastructureQuest, TotalDevelopmentQuest
- **Expansion**: SpecificExpansionQuest, ExpandToProvincesQuest
- **Military**: GrandArmyQuest, UpgradeBattalionQuest, FightBeastsAloneQuest
- **Resources**: WealthQuest, AlmsToProvinceQuest, AlmsAcrossRealmQuest, GiveToHeroesInProvinceQuest, GiveToHeroesAcrossRealmQuest
- **Personnel**: DismissSpecificVassalQuest, RescueImprisonedLeaderQuest
- **Prisoner**: ExecutePrisonerQuest, ExilePrisonerQuest, ReleasePrisonerQuest, ReturnPrisonerQuest
- **Province Orders**: DevelopProvincesQuest, MobilizeProvincesQuest
- **Events**: SuppressRiotByForceQuest
+471
View File
@@ -0,0 +1,471 @@
# 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?
+51 -114
View File
@@ -109,41 +109,11 @@ All 26 tracks have CC licenses with proper attribution:
| Dragon Castle | Makai Symphony | CC BY-SA 3.0 |
| Durandal | Makai Symphony | CC BY-SA 3.0 |
**Tracks with non-CC licenses:**
| Track | Artist | License | Source |
|-------|--------|---------|--------|
| Market Day | RandomMind | Free without attribution | [Chosic](https://www.chosic.com/download-audio/27016/) |
| Shopping List | Komiku | Free without attribution | [Chosic](https://www.chosic.com/download-audio/24714/) |
| Medieval: Victory Theme | RandomMind | CC0 Public Domain | [Chosic](https://www.chosic.com/download-audio/28492/) |
| No Time for Greatness | Dima Koltsov | CC BY 4.0 | [YouTube](https://www.youtube.com/watch?v=cQh0OWIFdgM) |
| Warriors of Demacia | Dima Koltsov | CC BY 4.0 | [YouTube](https://www.youtube.com/watch?v=yktSUMJn9ao) |
| Forest Queen Tale | Dima Koltsov | Presumed CC BY 4.0 | Not found on YouTube; other Dima Koltsov tracks are CC BY 4.0 |
| Valor | Dima Koltsov | CC BY 4.0 | [YouTube](https://www.youtube.com/watch?v=uoHYJRPcS2Y) |
| Clouds | Dima Koltsov | Presumed CC BY 4.0 | Not found on YouTube; other Dima Koltsov tracks are CC BY 4.0 |
**Note on Dima Koltsov tracks:** 3 of 5 tracks confirmed CC BY 4.0 via YouTube. 2 remaining tracks (Forest Queen Tale, Clouds) presumed same license but not verified.
---
## 2b. Creative Commons Sound Effects
**Location:** `Assets/Shardok/Sounds/`
| File | Description | Artist | License | Source |
|------|-------------|--------|---------|--------|
| `rain_loop.ogg` | Rain falling on clay roof tiles (loopable) | aesqe | CC BY 4.0 | [Freesound #37618](https://freesound.org/people/aesqe/sounds/37618/) |
| `blizzard_wind_loop.wav` | Wind draft loop (indoor recording, loops seamlessly) | nsstudios | CC BY 4.0 | [Freesound #651540](https://freesound.org/people/nsstudios/sounds/651540/) |
| `thunder_distant.mp3` | Distant thunder rumble | LittleRainySeasons | CC BY 4.0 | [Freesound #351526](https://freesound.org/people/LittleRainySeasons/sounds/351526/) |
| `thunder_loud.mp3` | Loud thunder clap | mokasza | CC BY 4.0 | [Freesound #810746](https://freesound.org/people/mokasza/sounds/810746/) |
| `thunder_crack.wav` | Thunder crack | OneSoundToRuleThemAll | CC BY 4.0 | [Freesound #238796](https://freesound.org/people/OneSoundToRuleThemAll/sounds/238796/) |
| `thunder_clap.wav` | Thunder clap | FreqMan | CC BY 4.0 | [Freesound #32544](https://freesound.org/people/FreqMan/sounds/32544/) |
**Location:** `Assets/Shardok/soundEffects/`
| File | Description | Artist | License | Source |
|------|-------------|--------|---------|--------|
| `runaway.mp3` | Medieval army running loop (gravel + metal/chain) | Yap_Audio_Production | CC BY 4.0 | [Freesound #218997](https://freesound.org/people/Yap_Audio_Production/sounds/218997/) |
**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
---
@@ -159,61 +129,42 @@ All 26 tracks have CC licenses with proper attribution:
## 4. Potentially Problematic Assets (Review Needed)
### ~~Clip Art (Unknown License)~~ RESOLVED
| File | Status |
|------|--------|
| ~~`Assets/Shardok/commandImages/bridge.png`~~ | **REPLACED** (2026-01-23) with AI-generated wooden rope bridge icon (ChatGPT/DALL-E 3, 512x512 PNG). No licensing restrictions - AI-generated for this project. |
| ~~`Assets/Images/startFire.png`~~ | **REPLACED** (2026-01-23) with "Flame Icon" from [UXWing](https://uxwing.com/flame-icon/) (free for commercial use, no attribution required). Consolidated duplicate removed. |
| ~~`Assets/Shardok/commandImages/startFire.png`~~ | **DELETED** (2026-01-23) - duplicate removed, all references updated to use `Assets/Images/startFire.png` |
### 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:** 37 audio files (was incorrectly counted as 56 including .meta files)
- **Count:** 56 MP3 files
- **Contents:** Spell effects, movement, combat sounds
- **Status:** Unknown origin - may be custom or need verification
**Verified from [Zombie Monster - Undead Collection](https://assetstore.unity.com/packages/audio/sound-fx/creatures/zombie-monster-undead-collection-70662) (Unity Asset Store):**
- `raise_undead.mp3`
- `undead_break_control.mp3`
- `undead_grew.wav`
### Free Icons
- **Location:** `Assets/free_icons/`
- **Count:** 8 PNG weather icons
- **Status:** Verify "free" means commercially usable
**⚠️ MUST REPLACE (1 remaining):**
- ~~`anybody.mp3`~~ - **REPLACED** (2026-01-23) with `Positive Effect 6.wav` from Magic Spells Sound Effects LITE
- ~~`burnination.mp3`~~ - **REPLACED** (2026-01-23) with `Magic Element Fire 04.wav` from Medieval Combat Sounds
- `failure_horn.mp3` - licensing issue, no replacement found in purchased assets
- ~~`runaway.mp3`~~ - **REPLACED** (2026-01-27) with `MedievalArmyRunningLoop.mp3` from Freesound (CC BY 4.0)
**Presumed from Unity Asset Store purchases (31 files):**
Owner believes these are from: Fantasy Interface Sounds, Medieval Combat Sounds, Magic Spells Sound Effects LITE, and/or Medieval Battle Sound Pack.
- `archery.mp3`, `battle_shout.mp3`, `boo.mp3`, `braved_water.mp3`
- `build_bridge.mp3`, `build_bridge_failure.mp3`, `charge.mp3`
- `dismiss_unit.mp3`, `duel_challenged.mp3`, `failure_horn.mp3`, `fear.mp3`, `fear_failed.mp3`
- `fire_extinguish.mp3`, `fire_spread.mp3`, `fire_start.mp3`, `fire_start_failure.mp3`
- `freeze.mp3`, `holy_wave.mp3`, `holy_wave_damage.mp3`, `jail_door.mp3`, `lightning.mp3`
- `melee.mp3`, `meteor.mp3`, `mind_control.mp3`, `move.mp3`, `move 1.mp3`
- `raging_fire.mp3`, `reduce.mp3`, `repair.mp3`, `repair_failed.mp3`, `splash.mp3`
### ~~Free Icons~~ RESOLVED
- **Location:** `Assets/free_icons/` - **DELETED** (2026-01-27)
- **Resolution:** All icons replaced with equivalents from purchased Asset Store packs:
- `blizzard.png``16_blizzard_nobg.png` (4000_Fantasy_Icons)
- `rain.png``12_Magic_rain_nobg.png` (4000_Fantasy_Icons)
- `thunderstorm.png``27_Storm_nobg.png` (4000_Fantasy_Icons)
- `wind.png``23_Light_blow_nobg.png` (4000_Fantasy_Icons)
- `thermometer.png``startFire.png` (existing licensed asset)
- `snow.png``16_blizzard_nobg.png` (4000_Fantasy_Icons)
- `cloud.png`, `sun.png` → deleted (unused)
### ~~Terrain Hexes~~ VERIFIED
### Terrain Hexes
- **Location:** `Assets/Terrain Hexes/`
- **Count:** 85 PNG files
- **Status:** ✓ Confirmed Unity Asset Store purchase (2026-01-23)
- **Status:** Unknown source - verify licensing
### ~~StrategyGameIcons~~ VERIFIED
### StrategyGameIcons
- **Location:** `Assets/StrategyGameIcons/`
- **Count:** 138 PNG files
- **Publisher:** REXARD
- **Asset Store Link:** https://assetstore.unity.com/packages/2d/gui/icons/strategy-game-icons-64816
- **Status:** ✓ Confirmed Unity Asset Store purchase (2026-01-23)
- **Status:** Unknown source - verify licensing
---
@@ -243,31 +194,28 @@ NuGet packages in `Assets/Packages/` all include LICENSE.TXT files:
## Action Items
### Must Replace Before Opening Public Access:
### Must Verify Before Opening Public Access:
1. ~~**Clip art images**~~ - **RESOLVED** (2026-01-23): Replaced with properly licensed alternatives
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. ~~**Shardok sound effects**~~ - **ALL RESOLVED**:
- ~~`anybody.mp3`~~ - **REPLACED** (2026-01-23)
- ~~`burnination.mp3`~~ - **REPLACED** (2026-01-23)
- ~~`failure_horn.mp3`~~ - **REPLACED** (2026-01-27) with `Negative Effect 04.wav` from Magic Spells Sound Effects LITE
- ~~`runaway.mp3`~~ - **REPLACED** (2026-01-27) with Freesound CC BY 4.0
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
### Low Priority (Verify):
3. **Shardok sound effects** - 56 MP3 files of unknown origin. Either:
- Document their source
- Replace with known-licensed alternatives
- Confirm they were custom-created
3. **Dima Koltsov tracks** - 2 of 5 not verified: `Forest Queen Tale`, `Clouds` (presumed CC BY 4.0 like his other tracks)
4. **Terrain Hexes** - 85 hex tiles of unknown source
- **TODO:** Confirm if this is a Unity Asset Store purchase (owner believes it is)
### Already Resolved:
5. **StrategyGameIcons** - 138 icons of unknown source
- **TODO:** Investigate origin - check Unity Asset Store purchase history
4. ~~**Free Icons**~~ - **RESOLVED** (2026-01-27): All replaced with Asset Store equivalents, folder deleted
6. **AUDIUS music tracks** - Verify Dima Koltsov tracks are licensed for commercial use
5. ~~**Terrain Hexes**~~ - **VERIFIED** (2026-01-23): Confirmed Unity Asset Store purchase
6. ~~**StrategyGameIcons**~~ - **VERIFIED** (2026-01-23): Unity Asset Store purchase (REXARD)
7. ~~**Medieval: Victory Theme**~~ - **VERIFIED** (2026-01-23): CC0 Public Domain by RandomMind ([Chosic](https://www.chosic.com/download-audio/28492/))
8. ~~**Discord logo**~~ - **OK** (2026-01-23): Usage complies with Discord brand guidelines for "Login with Discord" button
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:
@@ -281,23 +229,12 @@ NuGet packages in `Assets/Packages/` all include LICENSE.TXT files:
## Recommendation
**Remaining before public release:**
Before removing HTTP basic auth:
None! All required items resolved.
**Low priority:**
2. Verify 2 Dima Koltsov tracks (`Forest Queen Tale`, `Clouds`) - presumed CC BY 4.0
**Already resolved:**
- ~~Clip art images~~ **DONE** - replaced with properly licensed alternatives
- ~~Terrain Hexes~~ **DONE** - confirmed Asset Store purchase
- ~~StrategyGameIcons~~ **DONE** - Unity Asset Store (REXARD)
- ~~Medieval: Victory Theme~~ **DONE** - CC0 Public Domain
- ~~3 other Dima Koltsov tracks~~ **DONE** - confirmed CC BY 4.0
- ~~anybody.mp3, burnination.mp3~~ **DONE** - replaced
- ~~free_icons~~ **DONE** - replaced with Asset Store equivalents
- ~~failure_horn.mp3~~ **DONE** - replaced with Negative Effect 04.wav
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.
+304
View File
@@ -0,0 +1,304 @@
# 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 (January 2026)
### Summary
| Area | Proto Imports | Status |
|------|---------------|--------|
| AI (`/ai/`) | **0** | ✅ **Complete** |
| Actions (`/library/actions/impl/action/`) | **0** | ✅ **Complete** |
| Commands (`/library/actions/impl/command/`) | **0** | ✅ **Complete** |
| Availability (`/library/actions/availability/`) | **0** | ✅ **Complete** |
| Command Choice Helpers (`/library/util/command_choice_helpers/`) | **0** | ✅ **Complete** |
| View Filters (`/library/util/view_filters/`) | **0** | ✅ **Complete** (except boundary code) |
| Other Utilities (`/library/util/`) | **0** | ✅ **Complete** |
| NewGameCreation (`/service/new_game_creation/`) | **0** | ✅ **Complete** (except input GameParameters proto) |
| LLM Prompt Generators (`/library/actions/llm_prompt_generators/`) | **56** | ⏳ Remaining work (34 files) |
| LLM Request Generators (`/library/actions/llm_request_generators/`) | **2** | ⏳ Remaining work (1 file) |
| Root Library (`/library/`) | **7** | Boundary code (4 files) |
**Total: ~65 proto imports remaining** (down from 149)
### 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 actions converted to T-type base classes |
| Phase 5b-d: RoundPhaseAdvancer | **Complete** | Uses Scala GameState throughout |
| Phase 6: Core Engine | **Complete** | ActionResultApplier, RandomStateSequencer protoless |
| Phase 6b: CommandFactory | **Complete** | Uses Scala SelectedCommand and AvailableCommand |
| Phase 6c: AI Clients | **Complete** | AIClient, command choosers all protoless |
| Phase 6d: CommandSelection | **Complete** | Renamed ScalaCommandSelection → CommandSelection |
### Recent Completions (PRs #5326-#5390)
1. **CommandFactory protoless** - Now accepts/returns Scala `SelectedCommand` types
2. **AI clients protoless** - `AIClient`, `MidGameAIClient`, all command choosers use Scala types
3. **CommandSelection cleanup** - Deleted legacy proto-based `CommandSelection`, renamed `ScalaCommandSelection``CommandSelection`
4. **AvailableCommandsFactory protoless** - Returns Scala `OneProvinceAvailableCommands`
5. **All CommandChoiceHelpers protoless** - All selector/chooser files converted
6. **ChronicleEventGenerator protoless** (PR #5332) - Removed last proto enum import from Actions layer
7. **ProvinceUtils protoless** (PR #5333) - Converted to use Scala `BattalionType`
8. **FactionViewFilter protoless** (PR #5334) - Returns Scala `FactionView`, converts to proto at edge
9. **HeroViewFilter protoless** (PR #5336) - Returns Scala `HeroView`, converts to proto at edge
10. **ProvinceViewFilter protoless** (PR #5341) - `ProvinceView.knownEvents` uses Scala `ProvinceEvent`, converts at edge
11. **StatWithConditionUtils & ProvinceEventUtils protoless** (PR #5342) - Deleted unused proto overloads
12. **IncomingArmyUtils protoless** (PR #5348) - Deleted unused `stats()` method
13. **RansomOfferHelpers deleted** (PR #5351) - Deleted unused dead code file
14. **MapGenerator protoless** (PR #5373) - Returns Scala types instead of proto
15. **Remove proto ActionResult from Engine layer** (PR #5378) - Engine uses Scala `ActionResultT`
16. **NewGameCreation protoless** (PR #5381) - Uses Scala types throughout, no proto conversion
17. **PersistedHistory Scala API** (PR #5390) - New `apply` overload accepts `Vector[ActionResultT]` directly
---
## Remaining Work
### Phase 7: View Filters ✅ Complete
| File | Status | Migration Path |
|------|--------|----------------|
| `FactionViewFilter.scala` | ✅ **Complete** | Returns Scala `FactionView` (PR #5334) |
| `HeroViewFilter.scala` | ✅ **Complete** | Returns Scala `HeroView` (PR #5336) |
| `ProvinceViewFilter.scala` | ✅ **Complete** | Returns Scala `ProvinceView` with Scala events (PR #5341) |
| `GameStateViewFilter.scala` | ✅ **Complete** | Returns Scala `GameStateView`, converted at boundary |
| `GameStateViewDiffer.scala` | ✅ **Complete** | Uses Scala diff types internally, converted at boundary |
| `BattleFilter.scala` | Keep | Uses `ShardokBattleView` (boundary code for battles) |
**Pattern established**: Create Scala view type → Create converter → Update filter to return Scala type → Convert at edge.
### Phase 8: LLM Prompt Generators (~56 proto imports in 34 files)
The LLM prompt generators still use proto types for hero/faction/province data:
| File Category | Files | Proto Usage |
|---------------|-------|-------------|
| Diplomacy prompts | 12 | `Hero`, `Faction`, `Province` protos |
| Quest prompts | 4 | `Hero`, `Faction` protos |
| Chronicle prompts | 3 | `Hero`, `Faction`, `Province` protos |
| Other prompts | 15 | Various proto types |
**Migration strategy**: These files generate text for LLM prompts. They can be migrated to accept Scala types (`HeroT`, `FactionT`, `ProvinceT`) with converters at the call sites if needed.
**Priority**: Medium - These don't block other migrations and are isolated.
### Phase 9: Remaining Utilities ✅ Complete
| File | Proto Usage | Migration Path |
|------|-------------|----------------|
| `ProvinceEventUtils.scala` | ✅ **0** | Deleted unused proto overloads (PR #5342) |
| `StatWithConditionUtils.scala` | ✅ **0** | Deleted unused proto overloads (PR #5342) |
| `MapGenerator.scala` | ✅ **0** | Returns Scala types (PR #5373) |
| `IncomingArmyUtils.scala` | ✅ **0** | Deleted unused `stats()` method (PR #5348) |
| `GameStateViewDiffer.scala` | ✅ **0** | Uses Scala diff types, converts at boundary |
### Phase 10: History APIs ✅ Complete
**Completed:**
- `ActionWithResultingState` now has `scalaActionResult` property (lazy converted from proto)
- `ActionResultFilter.includeForPlayer` uses Scala types (`ActionResultT`, `NotificationT`, `ActionResultType`)
- `GameHistory.withNewResultsScala` preserves both Scala `GameState` and `ActionResultT` to avoid re-conversion
- `PersistedHistory.apply` now accepts `Vector[ActionResultT]` directly (PR #5390)
- `NewGameCreation` is fully protoless (PR #5381)
**Note:** History internals still use proto for persistence (expected - proto is good for disk serialization).
---
## Architecture Summary
### Fully Protoless Areas ✅
- **AI layer** (`/ai/`) - All AI clients and command choosers
- **Actions** (`/library/actions/impl/action/`) - All 52 action files
- **Commands** (`/library/actions/impl/command/`) - CommandFactory and all commands
- **Availability** (`/library/actions/availability/`) - AvailableCommandsFactory and all factories
- **Command helpers** (`/library/util/command_choice_helpers/`) - All selectors and choosers
- **Core types** - `CommandSelection`, `SelectedCommand`, `AvailableCommand`, `OneProvinceAvailableCommands`
- **View types** - `FactionView`, `HeroView`, `ProvinceView` (return Scala, convert at edge)
### Proto at Boundaries (Expected) ✅
- `EagleServiceImpl.scala` - gRPC boundary
- `GameController.scala` - Client communication
- `GameStateViewFilter.scala` - Converts Scala views to proto for client
- `*Converter.scala` - Explicit conversion utilities
- `PersistedHistory.scala` - Disk persistence
### Remaining Proto Usage ⏳
- LLM prompt generators (56 imports in 34 files) - Medium priority
- LLM request generators (2 imports in 1 file) - Medium priority
- BattleFilter (1 import) - Keep (boundary)
- Root library boundary code (7 imports in 4 files) - Keep (boundary)
---
## Estimated Remaining Effort
| Component | Files | Imports | Priority |
|-----------|-------|---------|----------|
| View Filters | 0 | 0 | ✅ Complete |
| Utility files | 0 | 0 | ✅ Complete |
| History APIs | 0 | 0 | ✅ Complete |
| LLM Prompt Generators | 34 | 56 | Medium |
| LLM Request Generators | 1 | 2 | Medium |
| Root Library (boundary) | 4 | 7 | Keep |
| **Total** | **~35** | **~65** | |
---
## Success Criteria
### Code Quality
- [x] Zero proto imports in `/ai/`
- [x] Zero proto imports in `/library/actions/impl/command/`
- [x] Zero proto imports in `/library/actions/availability/`
- [x] Zero proto imports in `/library/util/command_choice_helpers/`
- [x] Zero proto imports in `/library/actions/impl/action/`
- [x] Zero proto imports in `/library/util/view_filters/` (except BattleFilter boundary code)
- [x] Zero proto imports in `/library/util/` (except view filters)
- [x] Zero proto imports in `/service/new_game_creation/` (except input GameParameters)
- [ ] Zero proto imports in `/library/actions/llm_prompt_generators/`
- [ ] Zero proto imports in `/library/actions/llm_request_generators/`
### Architecture
- [x] Clear separation: Scala models (internal) vs Proto (boundaries)
- [x] Converters as the only bridge between domains
- [x] CommandFactory accepts/returns Scala types
- [x] AI layer fully protoless
- [x] FactionViewFilter returns Scala types
- [x] HeroViewFilter returns Scala types
- [x] ProvinceViewFilter returns Scala types
- [x] All view filters return Scala types
- [x] NewGameCreation uses Scala types
- [x] PersistedHistory accepts Scala types for new games
- [ ] LLM layer uses Scala types
- [ ] No "proto creep" into business logic
---
## Open Questions
1. **LLM Prompt Generators**: Should these accept Scala types directly, or is it acceptable to have proto usage here since they're generating text (not core game logic)?
2. ~~**GameStateViewDiffer**: This works with view protos for client updates. Should it remain proto-based since it's generating client-facing data?~~ **Resolved**: GameStateViewDiffer now uses Scala types internally (`GameStateViewDiff`, `ProvinceViewDiff`, etc.) and converts to proto at the boundary in `ActionResultFilter`.
3. **History Serialization**: Keep proto for persistence (good for schema evolution) or consider alternatives?
---
## Proto Import Inventory (Detailed)
**Current: ~65 proto imports across ~35 files** (as of 2026-01-17)
### Summary by Directory
| Directory | Files | Imports | Status |
|-----------|-------|---------|--------|
| `ai/` | 0 | 0 | ✅ Clean |
| `actions/availability/` | 0 | 0 | ✅ Clean |
| `actions/impl/action/` | 0 | 0 | ✅ Clean |
| `actions/impl/command/` | 0 | 0 | ✅ Clean |
| `actions/llm_prompt_generators/` | 34 | 56 | LLM request types |
| `actions/llm_request_generators/` | 1 | 2 | LLM request types |
| `util/command_choice_helpers/` | 0 | 0 | ✅ Clean |
| `util/view_filters/` | 0 | 0 | ✅ Clean (except boundary) |
| `util/` (other) | 0 | 0 | ✅ Clean |
| Root (`library/`) | 4 | 7 | Boundary code |
### util/view_filters/ (0 imports, except boundary)
| File | Import | Notes |
|------|--------|-------|
| `GameStateViewFilter.scala` | ✅ **0** | Returns Scala `GameStateView`, converts at edge |
| `GameStateViewDiffer.scala` | ✅ **0** | Uses Scala diff types, converts at edge |
| `BattleFilter.scala` | 1 | Battle view boundary (keep proto) |
### util/ other files ✅ Complete
| File | Imports | Notes |
|------|---------|-------|
| `MapGenerator.scala` | ✅ **0** | Returns Scala types (PR #5373) |
### Root library/ (7 imports, 4 files)
| File | Imports | Notes |
|------|---------|-------|
| `ActionResultFilter.scala` | 3 | Action result filtering (boundary, uses Scala types internally) |
| `Engine.scala` | 1 | Trait interface (returns `ActionResultView` proto for client) |
| `EngineImpl.scala` | 1 | Returns proto for persistence (boundary) |
| `ActionWithResultingState.scala` | 2 | Stores both proto and Scala (boundary for persistence) |
### actions/llm_prompt_generators/ (56 imports, 34 files)
These files generate LLM prompts and primarily use `internal.generated_text_request.*` types plus some common enums like `DiplomacyOfferStatus` and `BattalionTypeId`.
**Migration strategy**: Can be migrated to Scala types when convenient, but low priority as they're isolated from core game logic.
### actions/llm_request_generators/ (2 imports, 1 file)
| File | Imports | Notes |
|------|---------|-------|
| `DiplomacyResolutionLlmRequestGenerator.scala` | 2 | Uses `DiplomacyOfferStatus` enum and `GeneratedTextRequest` |
**Migration strategy**: Create Scala `DiplomacyOfferStatus` enum and `GeneratedTextRequest` types.
---
## 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`, 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
4. Delete `Legacy*` files when no longer needed
### Deleted Legacy Files (no production callers)
- `LegacyProvinceDistances`
- `LegacyBattalionSuitability`
- `LegacyFoodConsumptionUtils`
- `LegacyHandleRiotUtils`
-94
View File
@@ -1,94 +0,0 @@
# LLM Model Comparison
This document compares streaming latency (time-to-first-token) and pricing across OpenAI, Anthropic (Claude), and Google (Gemini) models for use in Eagle's narrative text generation.
## Test Methodology
All tests were performed locally using curl with streaming enabled. Each model was tested 3 times with the same prompt:
> "Write a short paragraph about a brave knight who discovers a hidden cave. Make it vivid and descriptive."
Time-to-first-token (TTFT) was measured from request initiation to the first text content appearing in the stream.
## Streaming Latency Results (January 2026)
| Model | Run 1 | Run 2 | Run 3 | Average TTFT |
|-------|-------|-------|-------|--------------|
| **Gemini 2.5 Flash-Lite** | 0.76s | 0.54s | 0.53s | **~0.6s** |
| **gpt-4.1-mini** | 1.65s | 1.72s | 1.68s | **~1.7s** |
| **claude-3-5-haiku** | 1.85s | 1.92s | 1.88s | **~1.9s** |
| gpt-5.2 | 3.25s | 3.38s | 3.32s | **~3.3s** |
| gpt-5-mini | 2.52s | 5.82s | 3.12s | **~3.8s** (high variance) |
| Gemini 3 Flash Preview | 4.11s | 4.77s | 4.28s | **~4.4s** |
| claude-sonnet-4 | 4.89s | 5.12s | 4.98s | **~5.0s** |
| Gemini 2.5 Flash | 5.60s | 7.00s | 7.79s | **~6.8s** |
## Pricing Comparison (per 1M tokens)
| Model | Input Price | Output Price | Notes |
|-------|-------------|--------------|-------|
| **Gemini 2.5 Flash-Lite** | $0.10 | $0.40 | Cheapest and fastest |
| Gemini 2.5 Flash | $0.15 | $0.60 | |
| gpt-5-mini | $0.25 | $2.00 | |
| **gpt-4.1-mini** | $0.40 | $1.60 | Best OpenAI value |
| Gemini 3 Flash Preview | $0.50 | $3.00 | Includes thinking tokens |
| **claude-3-5-haiku** | $0.80 | $4.00 | Best Anthropic value |
| gpt-5.2 | ~$1.00 | ~$10.00 | Full reasoning model |
| Gemini 2.5 Pro | $1.25 | $10.00 | |
| Gemini 3 Pro Preview | $2.00 | $12.00 | ≤200K context |
| claude-sonnet-4 | $3.00 | $15.00 | |
## Recommendations
### For Narrative Text Generation (Default)
**Gemini 2.5 Flash-Lite** is recommended as the default:
- Fastest TTFT (~0.6s) - nearly 3x faster than alternatives
- Cheapest pricing ($0.10/$0.40 per 1M tokens)
- Quality is acceptable for short narrative snippets
### Alternative Options
| Priority | Model | When to Use |
|----------|-------|-------------|
| Speed + Cost | Gemini 2.5 Flash-Lite | Default for most use cases |
| Speed + Quality | gpt-4.1-mini | When you need OpenAI quality with good speed |
| Instruction Following | claude-3-5-haiku | Complex multi-step prompts, consistent tone |
| Maximum Quality | claude-sonnet-4 or gpt-5.2 | When output quality is paramount |
### Quality Trade-offs
For short narrative snippets (1-3 paragraphs):
- **Flash-Lite vs Haiku/4.1-mini**: Minor quality difference, significant speed gain
- **Haiku vs Sonnet**: Noticeable quality difference in creative writing variety
- **gpt-4.1-mini vs gpt-5.2**: Moderate quality difference, significant cost savings
## Configuration
LLM settings can be changed at runtime via the admin console:
1. Navigate to Admin Console → Settings
2. Change `LlmProvider` to select vendor (gemini, openai, claude)
3. Change the corresponding model name setting:
- `GeminiModelName` (default: gemini-2.5-flash-lite)
- `OpenAiModelName` (default: gpt-4.1-mini)
- `ClaudeModelName` (default: claude-3-5-haiku-20241022)
Changes take effect on the next LLM request.
## Environment Variables
For production deployment, ensure API keys are set:
```bash
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
GEMINI_API_KEY=AIza...
```
## Notes
- **gpt-5-mini** showed high latency variance (2.5s - 5.8s) in testing
- **Gemini 2.5 Flash** was surprisingly slower than Flash-Lite, possibly due to internal reasoning overhead
- **Gemini 3 Flash** is a frontier model with better quality but higher latency than 2.5 Flash-Lite
- All Gemini models have a generous free tier (up to 1,000 daily requests)
-103
View File
@@ -1,103 +0,0 @@
# New Profession Proposals
This document proposes 5 new hero professions for Eagle0. Each profession has abilities for both the Eagle (strategic) and Shardok (tactical) game layers.
## Current Professions Reference
| Profession | Eagle Ability | Shardok Abilities |
|------------|---------------|-------------------|
| **Mage** | Control Weather | Lightning Bolt, Meteor, Freeze Water, Start Fire (enhanced) |
| **Necromancer** | Start Epidemic | Raise Dead, Fear |
| **Engineer** | (general) | Repair, Fortify, Build Bridge, Reduce (siege) |
| **Paladin** | Alms (prioritized) | Holy Wave |
| **Ranger** | Recon | Scout, Hide (enhanced), Brave Water (enhanced) |
| **Champion** | (general) | Challenge Duel |
---
## Proposed New Professions
### 1. HERALD (Morale & Communication Specialist)
**Fantasy:** The inspiring leader who rallies troops and carries messages across the battlefield.
**Eagle Ability: "Rally Province"**
- Spend vigor to boost recruitment in a province for one turn, or reduce unrest
- Synergy: Pairs well with provinces in turmoil or after losses
**Shardok Ability: "Inspire"**
- Target friendly unit within 3 hexes gains +1 action point this turn
- Creates interesting tactical choices about when to act vs. when to buff allies
- Cannot target self (prevents simple optimization)
---
### 2. ALCHEMIST (Fire & Transformation Specialist)
**Fantasy:** The mad scientist who manipulates the elements through science, not magic.
**Eagle Ability: "Transmute"**
- Convert one resource type to another in a province (gold to food or vice versa, at a loss)
- Provides economic flexibility during shortages
**Shardok Ability: "Wildfire"**
- Start a fire that spreads to 2 additional adjacent hexes immediately (not just at end of round)
- More aggressive than Mage's Start Fire but less controlled
- Cannot freeze water (that's magic, not science)
---
### 3. WARDEN (Defensive Specialist)
**Fantasy:** The stalwart defender who holds the line and protects allies.
**Eagle Ability: "Garrison"**
- A province with a Warden-led unit gets +1 to defense when attacked
- Encourages strategic placement of defensive heroes
**Shardok Ability: "Intercept"**
- Once per turn, when an adjacent friendly unit is attacked, the Warden's unit can take the hit instead
- Creates a "bodyguard" mechanic that protects valuable units
- Costs action points to maintain readiness
---
### 4. INQUISITOR (Anti-Magic & Intelligence)
**Fantasy:** The witch-hunter who counters supernatural threats and uncovers secrets.
**Eagle Ability: "Expose"**
- Reveal hidden information about enemy heroes in a province (stats, profession, vigor)
- Counter to Ranger's stealth/recon advantages
**Shardok Ability: "Dispel"**
- Cancel an active magical effect: stop a meteor cast, remove Fear from friendly unit, or reveal a hidden unit
- Direct counter to Mage and Necromancer abilities
- Creates meaningful profession rock-paper-scissors
---
### 5. BEASTMASTER (Animal Control)
**Fantasy:** The wild one who commands beasts and understands nature's fury.
**Eagle Ability: "Suppress Beasts" (Enhanced)**
- Already exists in game, but Beastmaster does it at reduced vigor cost
- Additionally: Can redirect beast attacks to enemy provinces instead of just suppressing
**Shardok Ability: "Beast Call"**
- Summon a wolf pack (weak undead-style unit) that attacks the nearest enemy
- Wolves act immediately but disappear at end of round
- Provides disposable units for screening or harassing archers
---
## Design Philosophy
These professions were designed to:
1. **Fill mechanical gaps**: Warden provides defensive depth, Inquisitor counters magic-heavy strategies
2. **Create counterplay**: Inquisitor vs Mage/Necromancer, Beastmaster vs Rangers (nature vs nature)
3. **Avoid overlap**: Each has a unique niche not covered by existing professions
4. **Support both layers**: Each ability is meaningful in its respective game mode
5. **Enable interesting decisions**: Intercept creates bodyguard tactics, Inspire creates action economy choices
-150
View File
@@ -1,150 +0,0 @@
# Notification Diff Batching Optimization
## Problem
`ActionResultFilter.filterForPlayer` generates per-action-result game state diffs, which is expensive. Profiling shows significant time spent in `filteredGameStateDiff``GameStateViewFilter.filteredGameState` (called twice per result) → `GameStateViewDiffer.diff`.
A potential optimization is to batch diffs: instead of computing N diffs for N action results, compute one combined diff representing the final state change. However, this is blocked by how client notification generators work.
## Current Architecture
### Server Side
1. `ActionResultFilter.filterForPlayer` processes each action result
2. For each result, computes `filteredGameStateDiff(before, after, factionId)`
3. Returns `Vector[ActionResultView]` where each view has its own `gameStateDiff`
### Client Side
1. `EagleGameModel.HandleNewHistoryEntry` processes each `ActionResultView`:
```csharp
private void HandleNewHistoryEntry(ActionResultView arv) {
_currentModel.HistoryCount++;
MaybeSendNotification(arv); // Uses currentModel state
ApplyGameStateViewDiff(arv.GameStateDiff); // Updates currentModel
}
```
2. Notification generators receive `(ActionResultView, IGameModel)` and look up display data from the model:
```csharp
var province = currentModel.Provinces[details.ProvinceId];
var factionName = currentModel.FactionName(details.FactionId);
var hero = currentModel.Heroes[heroId];
var affectedProvinces = currentModel.ProvincesForFaction(factionId);
```
### The Problem
Notification for action N sees model state after actions 1..N-1 have been applied. If we batch diffs, notification N would see model state before ANY actions, potentially showing stale data.
Example:
1. Action 1: Province X conquered by Faction B (was Faction A)
2. Action 2: Notification needs to show Province X's current ruler
With batching, action 2's notification would incorrectly show Faction A.
## Audit of Client Notification Generators
~50 generators access `currentModel`. Key lookup patterns:
| Lookup | Count | Mutable? | Risk |
|--------|-------|----------|------|
| `currentModel.PlayerId` | 22 | No | Safe |
| `currentModel.Provinces[id]` | 17 | Yes | `RulingFactionId` changes on conquest |
| `currentModel.Heroes[id]` | ~20 | Mostly safe | Hero data stable, used for display |
| `currentModel.FactionName(id)` | ~10 | No | Names don't change |
| `currentModel.MaybeDestroyedFaction(id)` | ~15 | Yes | Faction may be destroyed |
| `currentModel.ProvincesForFaction(id)` | ~15 | Yes | Changes on conquest |
### State-Changing Action Types
- `ProvinceConquered` - changes province ownership
- `FactionDestroyed` - removes faction
- `FactionLeaderRemoved` - changes faction head
## Proposed Solution: Server-Side Display Data
Eliminate client model lookups by including all display data in server-generated notifications.
### Current Notification Structure
```scala
case class NotificationC(
details: NotificationDetails,
targetFactionIds: Vector[FactionId],
affectedProvinceIds: Vector[ProvinceId], // Already exists, underused
affectedHeroIds: Vector[HeroId], // Already exists, underused
llm: NotificationT.Llm,
deferred: Boolean
)
```
### Proposed Changes
**Option A: Add fields to NotificationC**
```scala
case class NotificationC(
details: NotificationDetails,
targetFactionIds: Vector[FactionId],
affectedProvinceIds: Vector[ProvinceId],
affectedHeroIds: Vector[HeroId],
// New fields:
factionNames: Map[FactionId, String],
displayedHeroViews: Vector[HeroView],
provinceNames: Map[ProvinceId, String],
llm: NotificationT.Llm,
deferred: Boolean
)
```
**Option B: Enrich each NotificationDetails type**
```scala
case class TruceAccepted(
offeringFactionId: FactionId,
offeringFactionName: String, // New
targetFactionId: FactionId,
targetFactionName: String, // New
ambassadorHeroId: HeroId,
ambassadorHeroView: HeroView // New
)
```
### Implementation Steps
1. **Proto changes**: Add new fields to `Notification` message
2. **Server**: Populate display fields when creating notifications
3. **Client**: Update ~50 generators to use notification fields
4. **Test**: Add test that greps for `currentModel.` access in generators (excluding `PlayerId`)
5. **Server optimization**: With client decoupled from model state, batch diffs in `filterForPlayer`
### Trade-offs
**Pros:**
- Clean separation: server provides all display data
- Enables diff batching optimization
- Easier to reason about notification correctness
- Test can enforce the invariant
**Cons:**
- Larger notification messages (includes names, hero views)
- Proto changes required
- ~50 generators need updating (mechanical but tedious)
- Server must know what display data each notification type needs
## Alternative Approaches Considered
### A: Selective Per-Action Diffs
Only generate individual diffs for action results with notifications that need mutable model state. Requires tracking which notification types need which state.
**Rejected because:** Fragile; easy to add a new generator that breaks the invariant.
### B: State-Change-Triggered Diffs
If batch contains state-changing action types (ProvinceConquered, etc.), generate individual diffs from that point. Otherwise batch.
**Rejected because:** Still conservative; many batches would fall back to individual diffs.
## Status
**Deferred** - Current performance is acceptable. This doc captures the analysis for future reference if optimization becomes necessary.
## References
- `ActionResultFilter.scala` - Server-side filtering
- `EagleGameModel.cs` - Client-side model updates
- `Assets/Eagle/Notifications/` - All notification generators
- `NotificationT.scala` - Server notification types
+383
View File
@@ -0,0 +1,383 @@
# 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?
+189
View File
@@ -0,0 +1,189 @@
# 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.
+350
View File
@@ -0,0 +1,350 @@
# 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
+205
View File
@@ -0,0 +1,205 @@
# 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
-79
View File
@@ -1,79 +0,0 @@
# The Small Eagle TODO
## Goals
Be able to support a small (10-50 user) private alpha, including with strangers.
Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/17RTt3-4Wl2AAVMRLodaC3a84E4de6xuQTWBPvCRM484/edit?pli=1&tab=t.0), but most of that is not necessary for MVP.
## Required
### Gameplay Productionization
- [x] ~~All functionality works on production eagle / shardok servers~~
- [x] ~~Acceptable latency in all regions~~
- [x] ~~Shardok performance similar to QA~~
- [x] ~~Error logging & alerting~~
- [x] ~~Fix long disconnects on deployments~~
- [x] Fix the Mac installer
- [x] ~~Still not reconnecting after deployments~~
- [ ] Notify about client updates, button to come directly back
- [x] Generatedtext healing
- [x] Kill outstanding shardok requests when game is deleted
### Other Productionization
- [x] ~~Oauth sign-in~~
- [x] ~~Add Google, others?~~
- [ ] User management
- [x] ~~Invite codes~~
- [ ] Link accounts
- [x] ~~Choose display name~~
- [x] ~~Just do account setup from the landing page?~~
- [x] ~~Download client directly from DO, avoid basic auth/my home network~~
- [x] Support plan (Discord + in-client bug reporting + email contact)
### Alpha Tester Support
- [x] Feedback channel (Discord server)
- [x] Bug report form in Unity client (Settings > Report Bug, sends to Discord webhook)
- [ ] Known issues doc (so testers don't report the same things)
### IP / Legal
- [x] Document and make available licenses for art & music (attributions panel)
- [x] Required open source disclosures (attributions panel)
- [x] Audit assets for anything we don't have rights to and replace it
- [x] Replace heroes that are based on real 20th or 21st century people or IP
- [x] ~~Privacy policy (collecting accounts, OAuth data, gameplay data)~~ (alpha notice on invite page/email)
- [ ] Terms of service (basic liability protection) - defer until public release
- [x] ~~Data deletion capability (user requests account removal)~~ (accounts.eagle0.net)
### Basic Gameplay
- [ ] Tutorial
- [x] In the Your Warlord panel, say what the profession is
- [x] ~~And separate panels for each profession when you encounter one~~
- [x] ~~Command tutorial for each command the first time it's clicked~~
- [x] ~~Time to recruit / expand~~
- [x] ~~And how expansion works~~
- [ ] Province events
- [x] Running low on food
- [x] ~~Time to swear brotherhood~~
- [x] ~~When you get large, or~~
- [x] ~~When you get a good candidate~~
- [ ] Shardok tutorial!
- [ ] Basic Shardok AI stuff fixed
- [ ] Lobby fixes
- [ ] Have goals / ending
- [ ] Win condition: all other factions defeated
- [ ] Mid-game progression: King recognizes you as you gain power (generated events)
- [ ] First-session onboarding (beyond mechanics tutorial)
- [ ] Narrative hook in first few minutes - why should I care about my warlord?
- [ ] Clear first-session goal ("try to capture your first province" or similar)
- [ ] Early small victory to build momentum
- [ ] Guided first scenario vs. overwhelming sandbox?
## Nice to have
- [x] "What's new" changelog (fetch JSON, show entries since last launch)
-568
View File
@@ -1,568 +0,0 @@
# Sparkle Delta Updates Implementation Plan
## Overview
This document outlines the implementation plan for adding delta update support to the Eagle0 macOS auto-update system using Sparkle's BinaryDelta feature.
### Current State
- Full DMG downloads (~200MB) for every update
- `mac_build_handler.go` creates DMG, signs it, uploads to S3, updates appcast.xml
- Keeps last 10 versions in appcast, deletes older DMGs
- Users must download full app even for small changes
### Goals
- Reduce update download size from ~200MB to ~10-30MB (85% reduction)
- Maintain backward compatibility with full DMG downloads
- Automatic fallback for users who are many versions behind
## Sparkle Delta Update Architecture
Sparkle supports binary delta updates through the `<sparkle:deltas>` element in the appcast. When a user updates, Sparkle:
1. Checks if a delta patch exists from their current version to the new version
2. If found, downloads the smaller delta patch instead of the full DMG
3. Applies the patch locally to create the new app version
4. Falls back to full DMG if no matching delta exists
### Appcast XML Structure with Deltas
```xml
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>Eagle0</title>
<link>https://assets.eagle0.net/mac/appcast.xml</link>
<description>Eagle0 game updates</description>
<language>en</language>
<item>
<title>Version 1.0.9615</title>
<pubDate>Sun, 19 Jan 2026 12:00:00 -0800</pubDate>
<sparkle:version>9615</sparkle:version>
<sparkle:shortVersionString>1.0.9615</sparkle:shortVersionString>
<enclosure
url="https://assets.eagle0.net/mac/builds/eagle0-1.0.9615.dmg"
length="200000000"
type="application/octet-stream"
sparkle:edSignature="..." />
<sparkle:deltas>
<enclosure
url="https://assets.eagle0.net/mac/deltas/9614-9615.delta"
sparkle:deltaFrom="9614"
length="15000000"
type="application/octet-stream"
sparkle:edSignature="..." />
<enclosure
url="https://assets.eagle0.net/mac/deltas/9613-9615.delta"
sparkle:deltaFrom="9613"
length="18000000"
type="application/octet-stream"
sparkle:edSignature="..." />
<enclosure
url="https://assets.eagle0.net/mac/deltas/9612-9615.delta"
sparkle:deltaFrom="9612"
length="22000000"
type="application/octet-stream"
sparkle:edSignature="..." />
</sparkle:deltas>
</item>
<!-- older versions... -->
</channel>
</rss>
```
## Implementation Plan
### Phase 1: Add S3 Utility Functions
**File:** `src/main/go/net/eagle0/util/aws/bucket_basics.go`
Add two new functions to support delta generation:
```go
// ListObjectsWithPrefix returns all object keys matching the given prefix
func (bb BucketBasics) ListObjectsWithPrefix(bucket, prefix string) ([]string, error) {
var keys []string
paginator := s3.NewListObjectsV2Paginator(bb.S3Client, &s3.ListObjectsV2Input{
Bucket: aws.String(bucket),
Prefix: aws.String(prefix),
})
for paginator.HasMorePages() {
page, err := paginator.NextPage(context.TODO())
if err != nil {
return nil, err
}
for _, obj := range page.Contents {
keys = append(keys, *obj.Key)
}
}
return keys, nil
}
// DownloadFile downloads an object to a local file path
func (bb BucketBasics) DownloadFile(bucket, key, localPath string) error {
result, err := bb.S3Client.GetObject(context.TODO(), &s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
if err != nil {
return err
}
defer result.Body.Close()
file, err := os.Create(localPath)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(file, result.Body)
return err
}
```
### Phase 2: Store App Bundles for Delta Generation
**File:** `src/main/go/net/eagle0/build/mac_build_handler/mac_build_handler.go`
Add storage paths:
```go
var appsRoot = "mac/apps/" // Zipped app bundles for delta generation
var deltasRoot = "mac/deltas/" // Delta patches
```
After DMG creation, upload the zipped app bundle:
```go
func uploadAppBundle(bb aws.BucketBasics, appPath string, buildNumber string) error {
appZipPath := filepath.Join("/tmp", fmt.Sprintf("eagle0-%s.app.zip", buildNumber))
// Create zip of app bundle using ditto (preserves metadata)
cmd := exec.Command("ditto", "-c", "-k", "--keepParent", appPath, appZipPath)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to zip app: %s: %w", string(output), err)
}
defer os.Remove(appZipPath)
// Upload to S3
remotePath := appsRoot + fmt.Sprintf("eagle0-%s.app.zip", buildNumber)
log.Printf("Uploading app bundle to S3: %s", remotePath)
return bb.UploadFilePublic(bucketName, remotePath, appZipPath)
}
```
### Phase 3: Add Delta XML Structures
**File:** `src/main/go/net/eagle0/build/mac_build_handler/mac_build_handler.go`
Add new structs for delta representation:
```go
// Delta represents a delta patch from a previous version
type Delta struct {
XMLName xml.Name `xml:"enclosure"`
URL string `xml:"url,attr"`
DeltaFrom string `xml:"sparkle:deltaFrom,attr"`
Length int64 `xml:"length,attr"`
Type string `xml:"type,attr"`
EdSig string `xml:"sparkle:edSignature,attr"`
}
// Deltas wraps the sparkle:deltas element
type Deltas struct {
XMLName xml.Name `xml:"sparkle:deltas"`
Items []Delta `xml:"enclosure"`
}
// Update Item struct to include Deltas
type Item struct {
Title string `xml:"title"`
PubDate string `xml:"pubDate"`
SparkleVersion string `xml:"sparkle:version"`
SparkleShortVersion string `xml:"sparkle:shortVersionString"`
Description string `xml:"description,omitempty"`
Enclosure Enclosure `xml:"enclosure"`
Deltas *Deltas `xml:"sparkle:deltas,omitempty"`
}
```
### Phase 4: Generate Delta Patches
**File:** `src/main/go/net/eagle0/build/mac_build_handler/mac_build_handler.go`
```go
// Maximum number of versions to generate deltas from
const maxDeltaVersions = 5
// generateDeltas creates delta patches from previous versions to the new version
func generateDeltas(bb aws.BucketBasics, newBuildNumber string, newAppPath string, privateKeyPath string) ([]Delta, error) {
var deltas []Delta
// Ensure BinaryDelta tool is available
binaryDeltaPath, err := ensureBinaryDelta()
if err != nil {
return nil, fmt.Errorf("failed to get BinaryDelta: %w", err)
}
// List available app bundles
appKeys, err := bb.ListObjectsWithPrefix(bucketName, appsRoot+"eagle0-")
if err != nil {
log.Printf("Warning: failed to list app bundles: %v", err)
return deltas, nil // Continue without deltas
}
// Parse build numbers from keys and sort descending
var buildNumbers []string
for _, key := range appKeys {
// Extract build number from "mac/apps/eagle0-9614.app.zip"
base := filepath.Base(key)
if strings.HasPrefix(base, "eagle0-") && strings.HasSuffix(base, ".app.zip") {
bn := strings.TrimSuffix(strings.TrimPrefix(base, "eagle0-"), ".app.zip")
if bn != newBuildNumber {
buildNumbers = append(buildNumbers, bn)
}
}
}
// Sort descending (most recent first) and limit to maxDeltaVersions
sort.Sort(sort.Reverse(sort.StringSlice(buildNumbers)))
if len(buildNumbers) > maxDeltaVersions {
buildNumbers = buildNumbers[:maxDeltaVersions]
}
// Generate delta from each previous version
for _, oldBuild := range buildNumbers {
delta, err := generateSingleDelta(bb, binaryDeltaPath, oldBuild, newBuildNumber, newAppPath, privateKeyPath)
if err != nil {
log.Printf("Warning: failed to generate delta from %s: %v", oldBuild, err)
continue // Skip this delta but continue with others
}
deltas = append(deltas, delta)
}
return deltas, nil
}
func generateSingleDelta(bb aws.BucketBasics, binaryDeltaPath, oldBuild, newBuild, newAppPath, privateKeyPath string) (Delta, error) {
// Download old app bundle
oldAppZipKey := appsRoot + fmt.Sprintf("eagle0-%s.app.zip", oldBuild)
oldAppZipLocal := filepath.Join("/tmp", fmt.Sprintf("eagle0-%s.app.zip", oldBuild))
defer os.Remove(oldAppZipLocal)
if err := bb.DownloadFile(bucketName, oldAppZipKey, oldAppZipLocal); err != nil {
return Delta{}, fmt.Errorf("failed to download old app: %w", err)
}
// Unzip old app
oldAppDir := filepath.Join("/tmp", fmt.Sprintf("old-app-%s", oldBuild))
defer os.RemoveAll(oldAppDir)
cmd := exec.Command("ditto", "-x", "-k", oldAppZipLocal, oldAppDir)
if output, err := cmd.CombinedOutput(); err != nil {
return Delta{}, fmt.Errorf("failed to unzip old app: %s: %w", string(output), err)
}
oldAppPath := filepath.Join(oldAppDir, "eagle0.app")
// Generate delta
deltaPath := filepath.Join("/tmp", fmt.Sprintf("%s-%s.delta", oldBuild, newBuild))
defer os.Remove(deltaPath)
cmd = exec.Command(binaryDeltaPath, "create", oldAppPath, newAppPath, deltaPath)
if output, err := cmd.CombinedOutput(); err != nil {
return Delta{}, fmt.Errorf("failed to create delta: %s: %w", string(output), err)
}
// Get delta size
deltaSize, err := getFileSize(deltaPath)
if err != nil {
return Delta{}, fmt.Errorf("failed to get delta size: %w", err)
}
log.Printf("Delta %s->%s size: %d bytes (%.1f MB)", oldBuild, newBuild, deltaSize, float64(deltaSize)/1024/1024)
// Sign delta
signature, err := signWithSparkle(deltaPath, privateKeyPath)
if err != nil {
return Delta{}, fmt.Errorf("failed to sign delta: %w", err)
}
// Upload delta
deltaKey := deltasRoot + fmt.Sprintf("%s-%s.delta", oldBuild, newBuild)
if err := bb.UploadFilePublic(bucketName, deltaKey, deltaPath); err != nil {
return Delta{}, fmt.Errorf("failed to upload delta: %w", err)
}
deltaURL := fmt.Sprintf("https://assets.eagle0.net/%s", deltaKey)
return Delta{
URL: deltaURL,
DeltaFrom: oldBuild,
Length: deltaSize,
Type: "application/octet-stream",
EdSig: signature,
}, nil
}
func ensureBinaryDelta() (string, error) {
binaryDeltaPath := "/tmp/sparkle-cache/Sparkle-2.6.4/bin/BinaryDelta"
if _, err := os.Stat(binaryDeltaPath); os.IsNotExist(err) {
log.Println("Sparkle BinaryDelta not found, downloading...")
cmd := exec.Command("bash", "-c", `
mkdir -p /tmp/sparkle-cache
curl -sL https://github.com/sparkle-project/Sparkle/releases/download/2.6.4/Sparkle-2.6.4.tar.xz | tar -xJ -C /tmp/sparkle-cache
`)
if output, err := cmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("failed to download Sparkle: %s: %w", string(output), err)
}
}
return binaryDeltaPath, nil
}
```
### Phase 5: Update Main Deploy Flow
**File:** `src/main/go/net/eagle0/build/mac_build_handler/mac_build_handler.go`
Modify `main()` to integrate delta generation:
```go
func main() {
// ... existing argument parsing ...
// Create DMG (existing)
if err := createDMG(appPath, dmgPath, "Eagle0"); err != nil {
log.Fatalf("Failed to create DMG: %v", err)
}
// ... existing DMG upload ...
if privateKeyPath != "" {
// Upload app bundle for future delta generation (NEW)
log.Println("Uploading app bundle for delta generation...")
if err := uploadAppBundle(bb, appPath, buildNumber); err != nil {
log.Printf("Warning: failed to upload app bundle: %v", err)
// Continue - delta generation is optional
}
// Generate deltas from previous versions (NEW)
log.Println("Generating delta patches...")
deltas, err := generateDeltas(bb, buildNumber, appPath, privateKeyPath)
if err != nil {
log.Printf("Warning: failed to generate deltas: %v", err)
} else {
log.Printf("Generated %d delta patches", len(deltas))
}
// Update appcast with deltas
log.Println("Updating appcast.xml...")
appcast, err := fetchAppcast(bb)
if err != nil {
log.Fatalf("Failed to fetch appcast: %v", err)
}
// Create new item with deltas
newItem := Item{
Title: fmt.Sprintf("Version %s", version),
PubDate: time.Now().Format(time.RFC1123Z),
SparkleVersion: buildNumber,
SparkleShortVersion: version,
Description: "",
Enclosure: Enclosure{
URL: downloadURL,
Length: fileSize,
Type: "application/octet-stream",
EdSig: signature,
},
}
// Add deltas if any were generated
if len(deltas) > 0 {
newItem.Deltas = &Deltas{Items: deltas}
}
// ... rest of appcast handling ...
}
}
```
### Phase 6: Cleanup Old Artifacts
**File:** `src/main/go/net/eagle0/build/mac_build_handler/mac_build_handler.go`
When pruning old versions from appcast, also delete associated artifacts:
```go
// In the appcast pruning section, after removing old items:
if len(appcast.Channel.Items) > 10 {
oldItems := appcast.Channel.Items[10:]
for _, item := range oldItems {
oldBuild := item.SparkleVersion
// Delete old DMG (existing)
dmgKey := strings.TrimPrefix(item.Enclosure.URL, "https://assets.eagle0.net/")
log.Printf("Deleting old build: %s", dmgKey)
bb.DeleteObject(bucketName, dmgKey)
// Delete old app bundle (NEW)
appKey := appsRoot + fmt.Sprintf("eagle0-%s.app.zip", oldBuild)
log.Printf("Deleting old app bundle: %s", appKey)
bb.DeleteObject(bucketName, appKey)
// Delete deltas TO this version (NEW)
deltaKeys, _ := bb.ListObjectsWithPrefix(bucketName, deltasRoot)
for _, key := range deltaKeys {
if strings.HasSuffix(key, fmt.Sprintf("-%s.delta", oldBuild)) {
log.Printf("Deleting old delta: %s", key)
bb.DeleteObject(bucketName, key)
}
}
}
appcast.Channel.Items = appcast.Channel.Items[:10]
}
```
## S3 Storage Structure
After implementation, the S3 bucket will have this structure:
```
eagle0-windows/
├── mac/
│ ├── appcast.xml # Update feed with delta info
│ ├── builds/ # Full DMG downloads
│ │ ├── eagle0-1.0.9620.dmg
│ │ ├── eagle0-1.0.9619.dmg
│ │ ├── ...
│ │ └── eagle0-latest.dmg # Symlink to latest
│ ├── apps/ # Zipped app bundles (NEW)
│ │ ├── eagle0-9620.app.zip
│ │ ├── eagle0-9619.app.zip
│ │ ├── eagle0-9618.app.zip
│ │ ├── eagle0-9617.app.zip
│ │ └── eagle0-9616.app.zip # Keep last 5 for delta gen
│ └── deltas/ # Delta patches (NEW)
│ ├── 9619-9620.delta
│ ├── 9618-9620.delta
│ ├── 9617-9620.delta
│ ├── 9616-9620.delta
│ ├── 9615-9620.delta
│ ├── 9618-9619.delta
│ ├── 9617-9619.delta
│ └── ...
```
## Storage Impact Analysis
### Current Storage (without deltas)
- 10 DMGs × 200MB = **~2GB**
### Estimated Storage (with deltas)
- 10 DMGs × 200MB = 2GB
- 5 app bundles × 150MB = 0.75GB (zip compression)
- ~25 delta files × 20MB avg = 0.5GB
- **Total: ~3.25GB**
### Trade-offs
- **+1.25GB storage** (~60% increase)
- **-170MB per user update** (~85% bandwidth savings)
- Break-even: ~8 user updates to recoup storage cost
## Bandwidth Savings
| Scenario | Without Deltas | With Deltas | Savings |
|----------|---------------|-------------|---------|
| 1 version behind | 200MB | ~15MB | 92% |
| 2 versions behind | 200MB | ~20MB | 90% |
| 3 versions behind | 200MB | ~25MB | 87% |
| 5 versions behind | 200MB | ~35MB | 82% |
| 6+ versions behind | 200MB | 200MB (full) | 0% |
## Migration Strategy
The implementation is backward-compatible and requires no changes to existing clients:
1. **First deploy after implementation:**
- Stores app bundle for the first time
- No deltas generated (no previous app bundles exist)
- Appcast has no `<sparkle:deltas>` element
2. **Second deploy:**
- Generates delta from previous version
- Appcast now has `<sparkle:deltas>` with one entry
- Users on previous version get delta update
3. **Subsequent deploys:**
- Generate deltas from last 5 versions
- Users within 5 versions get delta updates
- Users more than 5 versions behind get full DMG
4. **Client behavior:**
- Sparkle automatically checks for matching delta
- Falls back to full DMG if no delta matches
- No client code changes required
## Error Handling
The implementation handles failures gracefully:
1. **S3 list/download fails:** Skip delta generation, use full DMG
2. **BinaryDelta fails for one version:** Log warning, continue with other versions
3. **Signing fails:** Skip that delta, continue with others
4. **Upload fails:** Skip that delta, continue with others
The deploy never fails due to delta issues - deltas are optional enhancements.
## Verification Plan
### Manual Testing
1. **Deploy version N:**
- Verify app bundle uploaded to `mac/apps/eagle0-N.app.zip`
- Verify appcast has no deltas (first deploy)
2. **Deploy version N+1:**
- Verify delta generated at `mac/deltas/N-(N+1).delta`
- Verify appcast contains `<sparkle:deltas>` element
- Verify delta signature is valid
3. **Test update from N to N+1:**
- Install version N manually
- Check for updates
- Monitor download size in Player.log (should be ~15-30MB, not 200MB)
- Verify app updated successfully
4. **Test fresh install:**
- Download latest DMG directly
- Verify installation works normally
5. **Test fallback scenario:**
- Install a version more than 5 versions behind
- Update should download full DMG
### Automated Verification
Add to CI workflow (optional):
```yaml
- name: Verify delta generation
run: |
# Check app bundle exists
aws s3 ls s3://eagle0-windows/mac/apps/ | grep eagle0-${BUILD_NUMBER}.app.zip
# Check deltas exist (after second deploy)
aws s3 ls s3://eagle0-windows/mac/deltas/ | head -5
# Verify appcast has deltas
curl -s https://assets.eagle0.net/mac/appcast.xml | grep "sparkle:deltas"
```
## Security Considerations
1. **All deltas are EdDSA signed:** Same signature verification as full DMG
2. **BinaryDelta is Sparkle's official tool:** Well-audited, production-ready
3. **App bundles in S3 are public:** Same as DMGs, no additional exposure
4. **Cleanup removes old artifacts:** No indefinite storage of old versions
## Future Enhancements
1. **Parallel delta generation:** Generate multiple deltas concurrently
2. **Delta size threshold:** Skip uploading deltas larger than X% of full DMG
3. **Delta metrics:** Track delta download rates vs full DMG
4. **Configurable delta count:** Allow adjusting how many versions to keep
+310
View File
@@ -0,0 +1,310 @@
# 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
-48
View File
@@ -1,48 +0,0 @@
# LLVM MinGW toolchain for Windows cross-compilation
# Provides x86_64-w64-mingw32 target compiler and libraries
package(default_visibility = ["//visibility:public"])
filegroup(
name = "all_files",
srcs = glob(["**/*"]),
)
# Compiler binaries
filegroup(
name = "compiler_files",
srcs = glob([
"bin/x86_64-w64-mingw32-*",
"bin/clang*",
"bin/llvm-*",
"bin/lld*",
]),
)
# Windows x86_64 sysroot (headers and libraries)
filegroup(
name = "windows_x86_64_sysroot",
srcs = glob([
"x86_64-w64-mingw32/**/*",
"generic-w64-mingw32/include/**/*",
]),
)
# All library files needed for linking
filegroup(
name = "linker_files",
srcs = glob([
"bin/x86_64-w64-mingw32-*",
"bin/lld*",
"bin/ld.lld*",
"lib/**/*",
"x86_64-w64-mingw32/lib/**/*",
]),
)
# The main C compiler wrapper script path for CGO
# CGO needs CC to point to the cross-compiler
exports_files([
"bin/x86_64-w64-mingw32-clang",
"bin/x86_64-w64-mingw32-clang++",
])
-2
View File
@@ -11,8 +11,6 @@ require (
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
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6
golang.org/x/sys v0.28.0
google.golang.org/grpc v1.68.0
google.golang.org/protobuf v1.36.3
)
-4
View File
@@ -41,10 +41,6 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6
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=
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 h1:VQpB2SpK88C6B5lPHTuSZKb2Qee1QWwiFlC5CKY4AW0=
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6/go.mod h1:yE65LFCeWf4kyWD5re+h4XNvOHJEXOCOuJZ4v8l5sgk=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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=
+98 -134
View File
@@ -1,19 +1,19 @@
{
"__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL",
"__INPUT_ARTIFACTS_HASH": 434250008,
"__RESOLVED_ARTIFACTS_HASH": -824975294,
"__INPUT_ARTIFACTS_HASH": -2049857450,
"__RESOLVED_ARTIFACTS_HASH": -1728186926,
"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.130.Final",
"io.netty:netty-codec-http2:4.1.110.Final": "io.netty:netty-codec-http2:4.1.130.Final",
"io.netty:netty-codec-http:4.1.110.Final": "io.netty:netty-codec-http:4.1.130.Final",
"io.netty:netty-codec:4.1.110.Final": "io.netty:netty-codec:4.1.130.Final",
"io.netty:netty-common:4.1.110.Final": "io.netty:netty-common:4.1.130.Final",
"io.netty:netty-handler:4.1.110.Final": "io.netty:netty-handler:4.1.130.Final",
"io.netty:netty-resolver:4.1.110.Final": "io.netty:netty-resolver:4.1.130.Final",
"io.netty:netty-transport-native-unix-common:4.1.110.Final": "io.netty:netty-transport-native-unix-common:4.1.130.Final",
"io.netty:netty-transport:4.1.110.Final": "io.netty:netty-transport:4.1.130.Final",
"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",
"io.netty:netty-codec:4.1.110.Final": "io.netty:netty-codec:4.1.112.Final",
"io.netty:netty-common:4.1.110.Final": "io.netty:netty-common:4.1.112.Final",
"io.netty:netty-handler:4.1.110.Final": "io.netty:netty-handler:4.1.112.Final",
"io.netty:netty-resolver:4.1.110.Final": "io.netty:netty-resolver:4.1.112.Final",
"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"
},
@@ -296,27 +296,27 @@
},
"io.netty:netty-buffer": {
"shasums": {
"jar": "00a522b67ea35cb7b4dd9cf27f85c6c58f5e306785aa045302e5f6b2d4944a87"
"jar": "bc182c48f5369d48cd8370d2ab0c5b8d99dd8ffa4a0f8ac701652d57bd380eff"
},
"version": "4.1.130.Final"
"version": "4.1.112.Final"
},
"io.netty:netty-codec": {
"shasums": {
"jar": "52636bc29bd62120b97bbe5d1d21eab9b1cb2bef8efbb54d2221c5f3fa08d8cd"
"jar": "72db4f93629f7ea520d2998c08e2b1d69f9c6a4792b53da5e9a001d24c78b151"
},
"version": "4.1.130.Final"
"version": "4.1.112.Final"
},
"io.netty:netty-codec-http": {
"shasums": {
"jar": "5b6addc1df7b3397a193bd6544a8bfdb18ecac99fd13bee4ec75b1781a664e5e"
"jar": "21b502d1374d6992728d004e0c1c95544d46d971f55ea78dcb854ce1ac0c83bc"
},
"version": "4.1.130.Final"
"version": "4.1.112.Final"
},
"io.netty:netty-codec-http2": {
"shasums": {
"jar": "f8ffdb550368fd5dee7c7f1393fa49552522f280ed8de96aebf4269cab0dc8f3"
"jar": "7f73efc845e8818d71da23b21dc65d69132dd0e12ed0e80cc937bd79ab7d5749"
},
"version": "4.1.130.Final"
"version": "4.1.112.Final"
},
"io.netty:netty-codec-socks": {
"shasums": {
@@ -326,15 +326,15 @@
},
"io.netty:netty-common": {
"shasums": {
"jar": "53921f28dd5a352b1bed0e1cbcc54d013dc60ffebeae9b2b1e53eabef317e581"
"jar": "b03967f32c65de5ed339b97729170e0289b22ffa5729e7f45f68bf6b431fb567"
},
"version": "4.1.130.Final"
"version": "4.1.112.Final"
},
"io.netty:netty-handler": {
"shasums": {
"jar": "98c78ec187ca30a4b9775bf6f632f5c9929db6bf06a60e6971f945813880ca0f"
"jar": "ea4d6062a5fb10a6e2364d8bbdebc1cfa814f1fc9f910ef57e5caf02fb15c588"
},
"version": "4.1.130.Final"
"version": "4.1.112.Final"
},
"io.netty:netty-handler-proxy": {
"shasums": {
@@ -344,9 +344,9 @@
},
"io.netty:netty-resolver": {
"shasums": {
"jar": "48c5b218a89d184e1b601d46433957f515fcefdb4464182b1348bce4f5a18f35"
"jar": "6b4ac9f3b67f562f0770d57c389279ff9c708eb401e1a3635f52297f0f897edc"
},
"version": "4.1.130.Final"
"version": "4.1.112.Final"
},
"io.netty:netty-tcnative-boringssl-static": {
"shasums": {
@@ -367,15 +367,15 @@
},
"io.netty:netty-transport": {
"shasums": {
"jar": "1bf573266d271f856705a9984d25449c56a1d73c02a16af12033ceccfe555dbb"
"jar": "d38e31624d25ca790ee413d529c152170217ebedbcdcf61164fa6291f3a56c92"
},
"version": "4.1.130.Final"
"version": "4.1.112.Final"
},
"io.netty:netty-transport-classes-epoll": {
"shasums": {
"jar": "1c352508d70aca1ccc3068daac907ad1e9d8dc68b07148d57426730bf0d0c8b6"
"jar": "96cf2e6622ea70e2bc67aed373607df32863f863d0d7b8c9c94d468efd1d0638"
},
"version": "4.1.130.Final"
"version": "4.1.112.Final"
},
"io.netty:netty-transport-native-epoll": {
"shasums": {
@@ -385,9 +385,9 @@
},
"io.netty:netty-transport-native-unix-common": {
"shasums": {
"jar": "cf5efc4168597d7cd14695b469418cac2a1134533f9a0c82ef0538d796fd39e1"
"jar": "e79ccea1b87a6348d4ebd3dfb37a2cccd9b7cb65c3375f6ccdac086c7b5ce487"
},
"version": "4.1.130.Final"
"version": "4.1.112.Final"
},
"io.opencensus:opencensus-api": {
"shasums": {
@@ -415,9 +415,9 @@
},
"io.sentry:sentry": {
"shasums": {
"jar": "bf4feb94944d03cc12e6d64ac8827d2328e3992744a3ce836f3f561e2c896209"
"jar": "740a118182fc089d307830f4e508372e01ad94639b00b4e1b1d83762298a5f35"
},
"version": "8.31.0"
"version": "7.19.0"
},
"javax.activation:javax.activation-api": {
"shasums": {
@@ -589,189 +589,183 @@
},
"software.amazon.awssdk:annotations": {
"shasums": {
"jar": "ed03bb4ff78900307dc96bacdec70ddf80c17df3eb6761219573025649f08ff3"
"jar": "a8faf8a259a3044758c3b6d2c53e33e7ec511bce9ef9b43a2a28af51483e868f"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:apache-client": {
"shasums": {
"jar": "37c1331e8e5e8bb8357b459258f4df7d367a7c0bb5ca36d640f688551484e98a"
"jar": "a63a91614d68fd7a72bd6e2f8e3c3a14dfca5b9768af30bc9b612c5592dd811e"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:arns": {
"shasums": {
"jar": "79074ed723d0bbc0eeb06ed8b30a1a5c1c109587706e284f6c02cb6e03c1732e"
"jar": "71a9f48c063ca66c2b72172254ade7a4207bb055290c668f42e49c744b11df69"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:auth": {
"shasums": {
"jar": "6ead038931e9348fc54f83f57b3788e7bbf2427d530e979206342c6bcd1c419d"
"jar": "91416554cbc4cb293847e9f79019e6eb292f73b2221b91d5c2f9704709030f72"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:aws-core": {
"shasums": {
"jar": "962c6a0037f054fafc38535c2ace8e2645a079ab7020068bd8c874f3112d8fe5"
"jar": "a7a36217cb47ab0025fad65cec0f4911ef7fa65ab3bcb21fefcfb41aa145021e"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:aws-query-protocol": {
"shasums": {
"jar": "46ef904b4f6ed9966a8f02255e2089b2b87d2028430dfca3e14b111bb4ef745b"
"jar": "34a9492bed2015c82587521e9761a4a19e4976a8e20956ace88f1dd2083b7714"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:aws-xml-protocol": {
"shasums": {
"jar": "199af876a21a7768b1690582c28792a83f7da0c398dc02c8e66f60fb57805622"
"jar": "027b5935a88bc8cbfee038c15344d209bc08d542d190f8598b28995e33474297"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:checksums": {
"shasums": {
"jar": "63685bbafb8c88c3cf184d83abf568b9bcbecd590494e471262d827ebf4500e8"
"jar": "19a304fbee84b6a0de316ddc71ae109167f8c6c9e64af9a5f968bc04860507dd"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:checksums-spi": {
"shasums": {
"jar": "b3dfcbb293b1ec7d2c7004c66b4dfeb1dc45b564e0974e1e7ecfb422cc558b8f"
"jar": "b17c77ca39e9b0e263fc9101cadfc8c0f80154b830b99d5941ff43133307215b"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:crt-core": {
"shasums": {
"jar": "a376745be7ff748996a3c11ae3ee5d702eac07197d1c4690c58d67dd313e6be6"
"jar": "9c9c11c2c81fe6bd2e1b2a0be998de2c4558e399e6eb4f2d8f0f5e2370522499"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:endpoints-spi": {
"shasums": {
"jar": "2176cf79255a9f7b5b3ab687b5b24a94faa0f626cd4368307db59d1a4f438d7e"
"jar": "327346ed4ac231de6c0b941b82a103e111c3398d6465680fe5325595ab8a72d1"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:http-auth": {
"shasums": {
"jar": "c1332af673bebee0485e0b92c6fdc9f2022339c0b34a39c56458f1b293dbfe96"
"jar": "1f3a01603ee338261326739b2305c75f06945fba095824b25d9618a831096663"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:http-auth-aws": {
"shasums": {
"jar": "01904850fba39ef2658931116ddb6ad00e3491e65ee09d4763ede7ce65b95574"
"jar": "cc12522d777a90ec8b24210889fc5a3304eb8b44c84a1663a1777add1697a571"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:http-auth-aws-eventstream": {
"shasums": {
"jar": "7294716ba8fd2f77ab917ca39ba0fad9e30c5d65329a98c6fde37bdd47aa0550"
"jar": "6c42fa55bc91bd9ffcfdce1ad27c40f53053ac331afa40f597eda9f55afafd58"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:http-auth-spi": {
"shasums": {
"jar": "1bec31a85ba3261e6e3ffde62f1a45834df1accf45262c42d0fd354c299752ba"
"jar": "363542d7632ec1d0b4950a0458a0ab67b0dfa9297c536ea518f2359c13e9fa66"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:http-client-spi": {
"shasums": {
"jar": "590ad7e05bdf79b4a873f47767a8cef8b906023f7cfbced58050c85af28bfd0b"
"jar": "da646c041506463aab868c6fd44148e743b177abc7e59c31c0a4d72cda6800de"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:identity-spi": {
"shasums": {
"jar": "8d8857f2f1243ccb6bb9c13beaa9e1ccce5087fe0a2599f4a801eafe2f8c0783"
"jar": "41712b35f2194e0551512762de18c5be5588d3ef0968f160bd1f9d18710fbb55"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:json-utils": {
"shasums": {
"jar": "7b8209883466083d3cd405679f1a75298e56926dc2e19640c377b3c8e2592969"
"jar": "3315c8ad3f1c71938d462e10435ecd0391f44441dcfe31e8a9d344fb7e43721f"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:metrics-spi": {
"shasums": {
"jar": "1f58e5147bcbb57f24ca6ab3534ca62fa7b35cc119eb0739cfb2a561a3c89c29"
"jar": "871fb80426158d296e3c7b695944aa2dcc4535f49a97d4a1616a7cdb21d66994"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:netty-nio-client": {
"shasums": {
"jar": "798550b0b59307a3d00801cc1dced02cde77c69268ff25455bb0aeeddecbe242"
"jar": "ce410946eb8f20421293ee9f6931e76d80a0c89d6ea31bdbf223ba07f316f2e3"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:profiles": {
"shasums": {
"jar": "b11c9b445f9bd16853ccf7130ae659ad76f2b749254c9920745c2a04230195b7"
"jar": "5789b27935bf6137884e35c86b3f3a246081209ecc511716b3d058e0e41c0719"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:protocol-core": {
"shasums": {
"jar": "8b0e21264c340b49e677b98c1303bd8916140e33cdad96bba8c8e831b7cbdd81"
"jar": "8e5bb55d4fe57951cbe6e06b66823f2bf5b5f93e9332ae6f2fc06599e0436544"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:regions": {
"shasums": {
"jar": "3d493a84884636d1fd6d8ef008e40d88639c9f8e38832d0cd99dc3ba3332c258"
"jar": "fb8020d6c948366384e1f669542170779764b5efdc127fa4b2b01d38e92c592a"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:retries": {
"shasums": {
"jar": "dbc0a5d16cc37cf325d60313e47ed41e9a683d46168920e72e13faa000bc7bcd"
"jar": "4a175be77663ddd5657d6919bf681fef71bf5782b5c00b683c73f32b639c6e3f"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:retries-spi": {
"shasums": {
"jar": "65aa3abb7bf5bac9ffac6ec20b5a70e7d4cb077f57edd3e4a0347180c75f82e5"
"jar": "57013438cf22c138a420c3c621a68202e0a7ee5f7a65de7681d9e2b308a01671"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:s3": {
"shasums": {
"jar": "06ca194e7bc633013a50ee8da497f83c2b275d2f41673ae63d0da2f61a352e90"
"jar": "2fa7b521592c0df9b9d5a8c6619132b84c9154fe2964ec32b408d386ad640169"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:s3-transfer-manager": {
"shasums": {
"jar": "c56152a84c1c291414f3e33523887f0a2cf551613b8a8eb4661fd9b66478b385"
"jar": "c05103cedc46ca416c4f5556561a113133d21deafe3c54682afb054ff0388647"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:sdk-core": {
"shasums": {
"jar": "f09e0cce359200dec42d5f8d59e1c7e4159dfa6d27c38acff176deccda348fa7"
"jar": "5b7d8a82c65af05f1ab789d3ca29aaf9a0fdc8656053e9114609feb49399a5bf"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:third-party-jackson-core": {
"shasums": {
"jar": "23e0c862fd5573c952bbf7fb35e7632a4328d37a188591e1e40f3c15d409690b"
"jar": "84e86f09375ebc378b390e968ad0c945af670241149e814e5fbe40ac87e0ae52"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.awssdk:utils": {
"shasums": {
"jar": "3d4bec1ab45fe5316337e285eb5f6729122388af538807927624c7f5cb5d4a91"
"jar": "38d869eeb74e9fc16ae1d707b919cbf71186f00920b81fe7a68f93a4db9b7d84"
},
"version": "2.41.18"
},
"software.amazon.awssdk:utils-lite": {
"shasums": {
"jar": "b42350e316ced8ef805f1443dc1716e6854865ae882729b2d21e8ce6416fc8fd"
},
"version": "2.41.18"
"version": "2.28.1"
},
"software.amazon.eventstream:eventstream": {
"shasums": {
@@ -1116,7 +1110,6 @@
],
"software.amazon.awssdk:auth": [
"software.amazon.awssdk:annotations",
"software.amazon.awssdk:checksums-spi",
"software.amazon.awssdk:http-auth",
"software.amazon.awssdk:http-auth-aws",
"software.amazon.awssdk:http-auth-aws-eventstream",
@@ -1145,7 +1138,6 @@
"software.amazon.awssdk:retries-spi",
"software.amazon.awssdk:sdk-core",
"software.amazon.awssdk:utils",
"software.amazon.awssdk:utils-lite",
"software.amazon.eventstream:eventstream"
],
"software.amazon.awssdk:aws-query-protocol": [
@@ -1167,8 +1159,7 @@
],
"software.amazon.awssdk:checksums": [
"software.amazon.awssdk:annotations",
"software.amazon.awssdk:checksums-spi",
"software.amazon.awssdk:utils"
"software.amazon.awssdk:checksums-spi"
],
"software.amazon.awssdk:checksums-spi": [
"software.amazon.awssdk:annotations"
@@ -1203,7 +1194,6 @@
"software.amazon.awssdk:http-auth-spi": [
"org.reactivestreams:reactive-streams",
"software.amazon.awssdk:annotations",
"software.amazon.awssdk:checksums-spi",
"software.amazon.awssdk:http-client-spi",
"software.amazon.awssdk:identity-spi",
"software.amazon.awssdk:utils"
@@ -1331,9 +1321,6 @@
"org.reactivestreams:reactive-streams",
"org.slf4j:slf4j-api",
"software.amazon.awssdk:annotations"
],
"software.amazon.awssdk:utils-lite": [
"software.amazon.awssdk:annotations"
]
},
"packages": {
@@ -1808,31 +1795,21 @@
"io.sentry",
"io.sentry.backpressure",
"io.sentry.cache",
"io.sentry.cache.tape",
"io.sentry.clientreport",
"io.sentry.config",
"io.sentry.exception",
"io.sentry.featureflags",
"io.sentry.hints",
"io.sentry.instrumentation.file",
"io.sentry.internal",
"io.sentry.internal.debugmeta",
"io.sentry.internal.eventprocessor",
"io.sentry.internal.gestures",
"io.sentry.internal.modules",
"io.sentry.internal.viewhierarchy",
"io.sentry.logger",
"io.sentry.metrics",
"io.sentry.opentelemetry",
"io.sentry.profilemeasurements",
"io.sentry.profiling",
"io.sentry.protocol",
"io.sentry.protocol.profiling",
"io.sentry.rrweb",
"io.sentry.transport",
"io.sentry.util",
"io.sentry.util.network",
"io.sentry.util.runtime",
"io.sentry.util.thread",
"io.sentry.vendor",
"io.sentry.vendor.gson.internal.bind.util",
@@ -2170,7 +2147,6 @@
],
"software.amazon.awssdk:aws-core": [
"software.amazon.awssdk.awscore",
"software.amazon.awssdk.awscore.auth",
"software.amazon.awssdk.awscore.client.builder",
"software.amazon.awssdk.awscore.client.config",
"software.amazon.awssdk.awscore.client.handler",
@@ -2182,13 +2158,11 @@
"software.amazon.awssdk.awscore.exception",
"software.amazon.awssdk.awscore.interceptor",
"software.amazon.awssdk.awscore.internal",
"software.amazon.awssdk.awscore.internal.auth",
"software.amazon.awssdk.awscore.internal.authcontext",
"software.amazon.awssdk.awscore.internal.client.config",
"software.amazon.awssdk.awscore.internal.defaultsmode",
"software.amazon.awssdk.awscore.internal.interceptor",
"software.amazon.awssdk.awscore.internal.token",
"software.amazon.awssdk.awscore.internal.useragent",
"software.amazon.awssdk.awscore.presigner",
"software.amazon.awssdk.awscore.retry",
"software.amazon.awssdk.awscore.retry.conditions",
@@ -2206,8 +2180,7 @@
"software.amazon.awssdk.protocols.xml.internal.unmarshall"
],
"software.amazon.awssdk:checksums": [
"software.amazon.awssdk.checksums",
"software.amazon.awssdk.checksums.internal"
"software.amazon.awssdk.checksums"
],
"software.amazon.awssdk:checksums-spi": [
"software.amazon.awssdk.checksums.spi"
@@ -2367,7 +2340,7 @@
"software.amazon.awssdk.core.internal",
"software.amazon.awssdk.core.internal.async",
"software.amazon.awssdk.core.internal.capacity",
"software.amazon.awssdk.core.internal.checksums",
"software.amazon.awssdk.core.internal.checksums.factory",
"software.amazon.awssdk.core.internal.chunked",
"software.amazon.awssdk.core.internal.compression",
"software.amazon.awssdk.core.internal.handler",
@@ -2403,7 +2376,6 @@
"software.amazon.awssdk.core.signer",
"software.amazon.awssdk.core.sync",
"software.amazon.awssdk.core.traits",
"software.amazon.awssdk.core.useragent",
"software.amazon.awssdk.core.util",
"software.amazon.awssdk.core.waiters"
],
@@ -2414,8 +2386,8 @@
"software.amazon.awssdk.thirdparty.jackson.core.exc",
"software.amazon.awssdk.thirdparty.jackson.core.filter",
"software.amazon.awssdk.thirdparty.jackson.core.format",
"software.amazon.awssdk.thirdparty.jackson.core.internal.shaded.fdp.v2_18_5",
"software.amazon.awssdk.thirdparty.jackson.core.io",
"software.amazon.awssdk.thirdparty.jackson.core.io.doubleparser",
"software.amazon.awssdk.thirdparty.jackson.core.io.schubfach",
"software.amazon.awssdk.thirdparty.jackson.core.json",
"software.amazon.awssdk.thirdparty.jackson.core.json.async",
@@ -2428,18 +2400,11 @@
"software.amazon.awssdk.utils.async",
"software.amazon.awssdk.utils.builder",
"software.amazon.awssdk.utils.cache",
"software.amazon.awssdk.utils.cache.bounded",
"software.amazon.awssdk.utils.cache.lru",
"software.amazon.awssdk.utils.http",
"software.amazon.awssdk.utils.internal",
"software.amazon.awssdk.utils.internal.async",
"software.amazon.awssdk.utils.internal.proxy",
"software.amazon.awssdk.utils.io",
"software.amazon.awssdk.utils.uri",
"software.amazon.awssdk.utils.uri.internal"
],
"software.amazon.awssdk:utils-lite": [
"software.amazon.awssdk.utilslite"
"software.amazon.awssdk.utils.internal.proxy"
],
"software.amazon.eventstream:eventstream": [
"software.amazon.eventstream"
@@ -2576,7 +2541,6 @@
"software.amazon.awssdk:sdk-core",
"software.amazon.awssdk:third-party-jackson-core",
"software.amazon.awssdk:utils",
"software.amazon.awssdk:utils-lite",
"software.amazon.eventstream:eventstream"
]
},
+2 -49
View File
@@ -39,7 +39,7 @@ http {
server {
listen 80;
listen [::]:80;
server_name prod.eagle0.net eagle0.net;
server_name prod.eagle0.net;
# Let's Encrypt challenge
location /.well-known/acme-challenge/ {
@@ -57,7 +57,7 @@ http {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name prod.eagle0.net eagle0.net;
server_name prod.eagle0.net;
# SSL certificates (managed by certbot)
ssl_certificate /etc/letsencrypt/live/prod.eagle0.net/fullchain.pem;
@@ -133,13 +133,6 @@ http {
proxy_set_header X-Real-IP $remote_addr;
}
# Credits/attributions page (proxied to Go auth service)
location /credits {
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;
@@ -269,44 +262,4 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# HTTP server for Accounts Console (Let's Encrypt + redirect)
server {
listen 80;
listen [::]:80;
server_name accounts.prod.eagle0.net accounts.eagle0.net;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS server for Accounts Console (user self-service portal)
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name accounts.prod.eagle0.net accounts.eagle0.net;
ssl_certificate /etc/letsencrypt/live/accounts.eagle0.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/accounts.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;
}
}
}
-129
View File
@@ -1,129 +0,0 @@
#!/bin/bash
# Check BUILD.bazel dependency constraints
# This script enforces architectural boundaries in the codebase.
#
# Usage:
# ./scripts/check_build_deps.sh # Check all rules
# ./scripts/check_build_deps.sh --ci # CI mode (fail on any violation)
# ./scripts/check_build_deps.sh --count # Just count current violations (for tracking progress)
# ./scripts/check_build_deps.sh --strict # Same as --ci (strict enforcement)
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
MODE="${1:-check}"
EXIT_CODE=0
# Rule 1: src/main should not depend on src/test
check_main_depends_on_test() {
echo -e "${YELLOW}Checking: src/main should not depend on src/test...${NC}"
violations=$(bazel query 'deps(//src/main/...) intersect //src/test/...' 2>/dev/null || true)
if [ -n "$violations" ]; then
echo -e "${RED}VIOLATION: src/main depends on src/test:${NC}"
echo "$violations"
return 1
else
echo -e "${GREEN}✓ No violations${NC}"
return 0
fi
}
# Rule 2: library/ should not depend on Scala proto types
# C++/Go proto deps are allowed (they're build-time deps for map generation tools)
check_library_depends_on_scala_proto() {
echo -e "${YELLOW}Checking: library/ should not depend on Scala proto types...${NC}"
violations=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | grep "_scala_proto" || true)
if [ -z "$violations" ]; then
count=0
else
count=$(echo "$violations" | grep -c "^//" || true)
fi
if [ "$count" -gt 0 ]; then
echo -e "${RED}VIOLATION: Found $count Scala proto dependencies in library/:${NC}"
echo "$violations"
return 1
else
echo -e "${GREEN}✓ No Scala proto dependencies in library/${NC}"
return 0
fi
}
# Rule 3: library/ should not depend on proto_converters
# Proto conversions should happen at service boundaries, not in library code
check_library_depends_on_proto_converters() {
echo -e "${YELLOW}Checking: library/ should not depend on proto_converters...${NC}"
violations=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/scala/net/eagle0/eagle/model/proto_converters/...' 2>/dev/null | grep "^//" || true)
if [ -n "$violations" ]; then
count=$(echo "$violations" | wc -l | tr -d ' ')
echo -e "${RED}VIOLATION: library/ depends on $count proto_converters targets:${NC}"
echo "$violations"
echo ""
echo "Proto conversions should happen at service boundaries (ShardokInterfaceGrpcClient,"
echo "EagleServiceImpl, etc.), not in library code."
return 1
else
echo -e "${GREEN}✓ No proto_converters dependencies in library/${NC}"
return 0
fi
}
# Count proto deps for informational purposes
count_proto_deps() {
echo -e "${YELLOW}=== Proto dependency counts ===${NC}"
scala_proto_results=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | grep "_scala_proto" || true)
if [ -z "$scala_proto_results" ]; then
scala_proto_count=0
else
scala_proto_count=$(echo "$scala_proto_results" | wc -l | tr -d ' ')
fi
echo "library/ Scala proto deps: $scala_proto_count"
# C++/Go proto deps are expected (map generation tools)
all_proto_count=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | wc -l | tr -d ' ')
echo "library/ all proto deps (includes C++/Go build tools): $all_proto_count"
}
echo "=== BUILD.bazel Dependency Check ==="
echo ""
case "$MODE" in
--count)
count_proto_deps
;;
--ci|--strict)
check_main_depends_on_test || EXIT_CODE=1
check_library_depends_on_scala_proto || EXIT_CODE=1
check_library_depends_on_proto_converters || EXIT_CODE=1
;;
*)
check_main_depends_on_test || EXIT_CODE=1
check_library_depends_on_scala_proto || EXIT_CODE=1
check_library_depends_on_proto_converters || EXIT_CODE=1
echo ""
count_proto_deps
;;
esac
echo ""
if [ $EXIT_CODE -eq 0 ]; then
echo -e "${GREEN}=== All checks passed ===${NC}"
else
echo -e "${RED}=== Some checks failed ===${NC}"
fi
exit $EXIT_CODE
+4 -38
View File
@@ -5,58 +5,24 @@
#
# Environment variables:
# SIGNING_IDENTITY - The signing identity (default: "Developer ID Application")
# KEYCHAIN_PASSWORD - Password to unlock the keychain (optional, CI only)
# KEYCHAIN_NAME - Name of the keychain containing the signing certificate (optional, CI only)
# When set, looks for certificate in this specific keychain.
# When not set, searches all keychains (local dev mode).
# 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}"
# KEYCHAIN_NAME is set by CI workflow - don't set a default here so we can detect if we're in CI
if [ ! -d "$APP_PATH" ]; then
echo "ERROR: App not found at $APP_PATH"
exit 1
fi
# Unlock keychain if password and keychain name are provided (CI environment)
if [ -n "${KEYCHAIN_PASSWORD:-}" ] && [ -n "${KEYCHAIN_NAME:-}" ]; then
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" || true
# Unlock keychain if password provided
if [ -n "${KEYCHAIN_PASSWORD:-}" ]; then
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain || true
fi
# Get the SHA-1 hash of the signing certificate
# Using the hash avoids "ambiguous" errors when the same identity exists in multiple keychains
# We look in the build keychain specifically if KEYCHAIN_NAME is set (CI environment)
# Otherwise fall back to searching all keychains (local dev)
if [ -n "${KEYCHAIN_NAME:-}" ]; then
# CI environment: look for certificate in the build keychain specifically
KEYCHAIN_PATH="$HOME/Library/Keychains/${KEYCHAIN_NAME}-db"
echo "Looking for signing identity in build keychain: $KEYCHAIN_PATH"
echo "Available identities in build keychain:"
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
CERT_HASH=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | grep -E '^\s+[0-9]+\)' | head -1 | awk '{print $2}')
else
# Local dev: search all keychains
echo "Available codesigning identities:"
security find-identity -v -p codesigning
CERT_HASH=$(security find-identity -v -p codesigning | grep -E '^\s+[0-9]+\)' | head -1 | awk '{print $2}')
fi
if [ -z "$CERT_HASH" ]; then
echo "ERROR: No valid signing identity found"
exit 1
fi
echo "Using certificate hash: $CERT_HASH"
# Use the hash as the signing identity to avoid ambiguity
SIGNING_IDENTITY="$CERT_HASH"
echo "=== Signing nested components first ==="
# Sign all dylibs
+3 -22
View File
@@ -35,7 +35,6 @@ 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"
ACTIVE_INSTANCE_FILE="${APP_DIR}/.active-instance"
# Colors for output
RED='\033[0;31m'
@@ -265,8 +264,7 @@ main() {
fi
# Recreate nginx to pick up new config
# Use --no-deps to prevent cascading to auth (which has secrets not available here)
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate --no-deps nginx
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate nginx
# Verify nginx picked up the correct config
local nginx_backend
@@ -299,10 +297,6 @@ main() {
create_flush_marker "${deploy_id}" "eagle-${staging}"
log_info "[DEPLOY:${deploy_id}] Flush marker created - server will auto-invalidate stale cache"
# Write active instance file for eagle-exec helper
echo "eagle-${staging}" > "${ACTIVE_INSTANCE_FILE}"
log_info "[DEPLOY:${deploy_id}] Active instance file updated: eagle-${staging}"
# Update .env for admin service
local env_file="${APP_DIR}/.env"
if [ "$staging" = "green" ]; then
@@ -317,22 +311,9 @@ main() {
echo "JFR_SIDECAR_ADDR=jfr-sidecar:8081" >> "${env_file}"
fi
# Restart admin to pick up new .env and ensure latest image
# Use --no-deps to prevent cascading to auth (which has secrets not available here)
# Restart admin to pick up new .env
log_info "Restarting admin service..."
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate --no-deps admin
# Verify admin container is running
sleep 3
if ! docker inspect admin-server --format '{{.State.Running}}' 2>/dev/null | grep -q "true"; then
log_error "Admin container failed to start!"
log_error "Container logs:"
docker logs admin-server --tail 20 2>&1 || true
log_error "Container inspect:"
docker inspect admin-server 2>&1 | head -50 || true
exit 1
fi
log_info "Admin service restarted successfully"
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate admin
# Clean up old instance
log_info "Cleaning up old eagle-${active}..."
-46
View File
@@ -1,46 +0,0 @@
#!/bin/bash
#
# Helper to run docker exec against the active Eagle instance.
# Reads the active instance from /opt/eagle0/.active-instance (set by deploy-blue-green.sh).
#
# Usage:
# eagle-exec printenv GEMINI_API_KEY
# eagle-exec jcmd 1 VM.flags
# eagle-exec sh # Get a shell
#
# To create an alias, add to ~/.bashrc:
# alias eagle-exec='/opt/eagle0/scripts/eagle-exec.sh'
#
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/eagle0}"
ACTIVE_FILE="${APP_DIR}/.active-instance"
# Read active instance from file, with fallback
if [ -f "$ACTIVE_FILE" ]; then
ACTIVE=$(cat "$ACTIVE_FILE")
else
# Fallback: check which container is actually running
if docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null | grep -q true; then
ACTIVE="eagle-blue"
elif docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null | grep -q true; then
ACTIVE="eagle-green"
else
ACTIVE=""
fi
fi
if [ -z "$ACTIVE" ]; then
echo "Error: No active Eagle instance found" >&2
exit 1
fi
if [ $# -eq 0 ]; then
echo "Active instance: $ACTIVE"
echo "Usage: $0 <command> [args...]"
echo "Example: $0 printenv GEMINI_API_KEY"
exit 0
fi
exec docker exec "$ACTIVE" "$@"
-44
View File
@@ -1,44 +0,0 @@
#!/bin/bash
#
# Helper to tail logs from the active Eagle instance.
# Reads the active instance from /opt/eagle0/.active-instance (set by deploy-blue-green.sh).
#
# Usage:
# eagle-logs # Tail logs (follow mode)
# eagle-logs -n 100 # Show last 100 lines and follow
# eagle-logs --no-follow -n 50 # Show last 50 lines without following
#
# To create an alias, add to ~/.bashrc:
# alias eagle-logs='/opt/eagle0/scripts/eagle-logs.sh'
#
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/eagle0}"
ACTIVE_FILE="${APP_DIR}/.active-instance"
# Read active instance from file, with fallback
if [ -f "$ACTIVE_FILE" ]; then
ACTIVE=$(cat "$ACTIVE_FILE")
else
# Fallback: check which container is actually running
if docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null | grep -q true; then
ACTIVE="eagle-blue"
elif docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null | grep -q true; then
ACTIVE="eagle-green"
else
ACTIVE=""
fi
fi
if [ -z "$ACTIVE" ]; then
echo "Error: No active Eagle instance found" >&2
exit 1
fi
# Default to follow mode if no args provided
if [ $# -eq 0 ]; then
exec docker logs -f "$ACTIVE"
else
exec docker logs "$@" "$ACTIVE"
fi
-129
View File
@@ -373,115 +373,6 @@ send_email_fastmail() {
echo "Email sent successfully"
}
# Generate player-friendly "What's New" summary
# This creates a short summary suitable for in-game display
generate_whats_new_summary() {
local pr_file="$1"
local output_file="/tmp/eagle0_whats_new_$$.txt"
echo "Generating player-friendly What's New summary..." >&2
# Create a prompt for player-facing summary
local prompt_file="/tmp/eagle0_whats_new_prompt_$$.txt"
cat > "$prompt_file" <<'WHATS_NEW_PROMPT'
Based on these merged PRs, write a SHORT player-friendly summary.
Focus only on changes players will notice. Ignore internal/technical changes.
Output format (exactly this format, no markdown, no extra text):
TITLE: (5-10 words describing the main change)
DESCRIPTION: (1-2 sentences, what players can now do differently)
CATEGORY: (one of: feature, improvement, fix, content)
If there are multiple notable player-visible changes, pick the single most important one.
If there are no player-visible changes at all, output exactly: NONE
Examples of good output:
TITLE: Bug Reporting
DESCRIPTION: You can now report bugs directly from the Settings menu.
CATEGORY: feature
TITLE: Faster Reconnection
DESCRIPTION: The game now reconnects more smoothly after network interruptions.
CATEGORY: improvement
Here are the merged PRs:
WHATS_NEW_PROMPT
cat "$pr_file" >> "$prompt_file"
# Use Claude CLI to generate the summary
if ! command -v claude &> /dev/null; then
echo "NONE"
rm -f "$prompt_file"
return
fi
cat "$prompt_file" | claude --print > "$output_file" 2>/dev/null
rm -f "$prompt_file"
# Check if the output is NONE
if grep -q "^NONE$" "$output_file"; then
echo "NONE"
rm -f "$output_file"
return
fi
# Return the output
cat "$output_file"
rm -f "$output_file"
}
# URL encode a string for use in query parameters
urlencode() {
local string="$1"
local strlen=${#string}
local encoded=""
local pos c o
for (( pos=0 ; pos<strlen ; pos++ )); do
c=${string:$pos:1}
case "$c" in
[-_.~a-zA-Z0-9] ) o="${c}" ;;
* ) printf -v o '%%%02x' "'$c"
esac
encoded+="${o}"
done
echo "${encoded}"
}
# Open the admin console with pre-populated what's new content
open_whats_new_preview() {
local title="$1"
local description="$2"
local category="$3"
local encoded_title=$(urlencode "$title")
local encoded_desc=$(urlencode "$description")
local encoded_cat=$(urlencode "$category")
local preview_url="https://admin.eagle0.net/whats-new?preview=true&title=${encoded_title}&description=${encoded_desc}&category=${encoded_cat}"
echo ""
echo "=== What's New Preview ==="
echo "Title: $title"
echo "Description: $description"
echo "Category: $category"
echo ""
echo "Opening admin console preview..."
# Open the URL in the default browser
if [[ "$(uname)" == "Darwin" ]]; then
open "$preview_url"
elif command -v xdg-open &> /dev/null; then
xdg-open "$preview_url"
else
echo "Preview URL: $preview_url"
fi
}
# Update the tag to mark this run
update_tag() {
echo "Updating $TAG_NAME tag..."
@@ -571,26 +462,6 @@ main() {
# Update tag for next run
update_tag
# Generate player-friendly What's New summary and open admin preview
echo ""
echo "Generating What's New summary for in-game display..."
whats_new_output=$(generate_whats_new_summary "$pr_file")
if [[ "$whats_new_output" != "NONE" ]]; then
# Parse the output
title=$(echo "$whats_new_output" | grep "^TITLE:" | sed 's/^TITLE:[[:space:]]*//')
description=$(echo "$whats_new_output" | grep "^DESCRIPTION:" | sed 's/^DESCRIPTION:[[:space:]]*//')
category=$(echo "$whats_new_output" | grep "^CATEGORY:" | sed 's/^CATEGORY:[[:space:]]*//')
if [[ -n "$title" && -n "$description" ]]; then
open_whats_new_preview "$title" "$description" "$category"
else
echo "Could not parse What's New output, skipping preview"
fi
else
echo "No player-visible changes detected, skipping What's New preview"
fi
fi
echo ""
+1 -1
View File
@@ -110,7 +110,7 @@ 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 'net.eagle0.eagle0'" "$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"
+6 -18
View File
@@ -50,34 +50,22 @@ if [ "$STATUS" != "Accepted" ]; then
exit 1
fi
echo "=== Verifying code signature before stapling ==="
if ! codesign --verify --deep --strict "$APP_PATH" 2>&1; then
echo "ERROR: Code signature verification failed - app may have been damaged during transfer"
echo "Attempting to show signature details:"
codesign -dvvv "$APP_PATH" 2>&1 || true
exit 1
fi
echo "Code signature verified successfully"
echo "=== Stapling notarization ticket to app ==="
# Retry stapling - Apple's CloudKit can take several minutes to propagate the ticket
MAX_STAPLE_ATTEMPTS=10
STAPLE_WAIT_SECONDS=30
# 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 STAPLE_OUTPUT=$(xcrun stapler staple "$APP_PATH" 2>&1); then
echo "$STAPLE_OUTPUT"
if xcrun stapler staple "$APP_PATH"; then
echo "Stapling successful"
break
fi
echo "Stapler output: $STAPLE_OUTPUT"
if [ $STAPLE_ATTEMPT -eq $MAX_STAPLE_ATTEMPTS ]; then
echo "ERROR: Stapling failed after $MAX_STAPLE_ATTEMPTS attempts (total wait: $((MAX_STAPLE_ATTEMPTS * STAPLE_WAIT_SECONDS)) seconds)"
echo "ERROR: Stapling failed after $MAX_STAPLE_ATTEMPTS attempts"
exit 1
fi
echo "Stapling failed, waiting $STAPLE_WAIT_SECONDS seconds before retry..."
sleep $STAPLE_WAIT_SECONDS
echo "Stapling failed, waiting 10 seconds before retry..."
sleep 10
STAPLE_ATTEMPT=$((STAPLE_ATTEMPT + 1))
done
@@ -37,7 +37,4 @@ sysinfo.txt
*.apk
*.unitypackage
mono_crash.*
# Local server data
ServerData/
mono_crash.*
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More