Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 4.5 a0817c9652 WIP: Local Unity changes
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 16:28:13 -08:00
2121 changed files with 59236 additions and 98021 deletions
+4 -8
View File
@@ -29,17 +29,13 @@ common --javacopt="-Xlint:-options"
# Use host_linkopt for macOS-specific flags to avoid passing them to Linux cross-compilation
common:macos --host_linkopt=-Wl,-no_warn_duplicate_libraries
# Workaround for grpc cf_event_engine build error on macOS with Bazel 7.x
# See: https://github.com/grpc/grpc/issues/37619
common:macos --features=-module_maps
# Fix Xcode version caching issue - avoids need for `bazel clean --expunge` after Xcode updates
common:macos --repo_env=DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
common --java_language_version=25
common --java_runtime_version=remotejdk_25
common --tool_java_language_version=25
common --tool_java_runtime_version=remotejdk_25
common --java_language_version=17
common --java_runtime_version=remotejdk_17
common --tool_java_language_version=17
common --tool_java_runtime_version=remotejdk_17
# Workspace status for build stamping (git commit, timestamp)
common --workspace_status_command=tools/workspace_status.sh
+1 -1
View File
@@ -1 +1 @@
8.5.1
7.6.1
-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
+2 -4
View File
@@ -27,12 +27,10 @@ jobs:
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 | \
--paginate -q '.artifacts[] | "\(.size_in_bytes)\t\(.name)\t\(.created_at)"' | \
sort -rn | 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
+2 -10
View File
@@ -9,9 +9,7 @@ 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'
- '.bazelrc'
- '.github/workflows/auth_build.yml'
workflow_dispatch:
inputs:
@@ -78,14 +76,8 @@ jobs:
# Build the push target to get crane in runfiles
bazel build //ci:auth_server_push
# Find crane binary in runfiles (path varies by Bazel version)
RUNFILES="bazel-bin/ci/push_auth_server_push.sh.runfiles"
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
if [ -z "$CRANE" ] || [ ! -x "$CRANE" ]; then
echo "ERROR: crane not found in runfiles"
find "$RUNFILES" -name crane 2>/dev/null || echo "No crane found at all"
exit 1
fi
# Use crane directly for push
CRANE="bazel-bin/ci/push_auth_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
echo "Using crane: $CRANE"
# Push with SHA tag
-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 .
+2 -11
View File
@@ -8,7 +8,6 @@ on:
- 'WORKSPACE'
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.bazelrc'
- '.github/workflows/bazel_test.yml'
- '!src/main/csharp/**'
- '!src/test/csharp/**'
@@ -18,7 +17,6 @@ on:
- 'WORKSPACE'
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.bazelrc'
- '.github/workflows/bazel_test.yml'
- '!src/main/csharp/**'
- '!src/test/csharp/**'
@@ -27,8 +25,9 @@ permissions:
contents: read
jobs:
lint:
test:
runs-on: [self-hosted, bazel]
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -36,14 +35,6 @@ jobs:
lfs: false
- name: Check BUILD.bazel dependencies
run: ./scripts/check_build_deps.sh --strict
test:
runs-on: [self-hosted, bazel]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Run tests
run: bazel test --build_event_json_file=test.json //src/test/... //src/main/go/...
- name: Collect failed test logs
+15 -38
View File
@@ -19,7 +19,6 @@ on:
- 'src/main/resources/**'
- 'ci/BUILD.bazel'
- 'MODULE.bazel'
- '.bazelrc'
- 'docker-compose.prod.yml'
- 'nginx/**'
- '.github/workflows/docker_build.yml'
@@ -69,11 +68,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)
@@ -112,12 +111,16 @@ jobs:
# Get crane from push target runfiles
bazel build //ci:eagle_server_push
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
CRANE="bazel-bin/ci/push_eagle_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
if [ -z "$CRANE" ] || [ ! -x "$CRANE" ]; then
echo "ERROR: crane not found in runfiles"
find "$RUNFILES" -name crane 2>/dev/null || echo "No crane found at all"
if [ ! -e "$CRANE" ]; then
# Fallback: find any Darwin crane
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
fi
if [ -z "$CRANE" ] || [ ! -e "$CRANE" ]; then
echo "ERROR: crane not found"
exit 1
fi
echo "Using crane: $CRANE"
@@ -164,9 +167,6 @@ jobs:
EAGLE_ENABLE_S3: ${{ secrets.EAGLE_ENABLE_S3 }}
DO_SPACES_ACCESS_KEY: ${{ secrets.DO_SPACES_ACCESS_KEY }}
DO_SPACES_SECRET_KEY: ${{ secrets.DO_SPACES_SECRET_KEY }}
# Admin server uses different credentials (for eagle0-assets bucket)
ADMIN_S3_ACCESS_KEY: ${{ secrets.ACCESS_KEY_ID }}
ADMIN_S3_SECRET_KEY: ${{ secrets.SECRET_KEY }}
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }}
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
@@ -188,7 +188,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 +201,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: |
@@ -288,8 +287,6 @@ jobs:
export EAGLE_ENABLE_S3="${EAGLE_ENABLE_S3:-false}"
export DO_SPACES_ACCESS_KEY="${DO_SPACES_ACCESS_KEY}"
export DO_SPACES_SECRET_KEY="${DO_SPACES_SECRET_KEY}"
export ADMIN_S3_ACCESS_KEY="${ADMIN_S3_ACCESS_KEY}"
export ADMIN_S3_SECRET_KEY="${ADMIN_S3_SECRET_KEY}"
export JWT_PRIVATE_KEY="${JWT_PRIVATE_KEY}"
export DISCORD_CLIENT_ID="${DISCORD_CLIENT_ID}"
export DISCORD_CLIENT_SECRET="${DISCORD_CLIENT_SECRET}"
@@ -310,7 +307,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
@@ -334,8 +330,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
@@ -388,24 +382,7 @@ 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 - remove old images to prevent disk full
# Cleanup
docker container prune -f
docker image prune -f
# Remove images older than 24h (keeps recent images for rollback)
docker image prune -a -f --filter "until=24h" || true
DEPLOY_SCRIPT
-2
View File
@@ -10,7 +10,6 @@ on:
- 'WORKSPACE'
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.bazelrc'
- '.github/workflows/eagle_build.yml'
pull_request:
paths:
@@ -20,7 +19,6 @@ on:
- 'WORKSPACE'
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.bazelrc'
- '.github/workflows/eagle_build.yml'
permissions:
+7 -34
View File
@@ -5,17 +5,13 @@ on:
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"
- "src/main/csharp/net/eagle0/clients/unity/**"
- "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"
- "src/main/csharp/net/eagle0/clients/unity/**"
- "ci/github_actions/build_ios_addressables.sh"
- "ci/github_actions/upload_addressables.sh"
workflow_dispatch:
@@ -23,38 +19,18 @@ on:
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
lfs: true
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: Pull LFS files
run: git lfs pull
- name: Restore Library/
env:
@@ -62,7 +38,7 @@ jobs:
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"
run: ./ci/github_actions/build_ios_addressables.sh "/tmp/eagle0/editor_ios_addressables.log"
- name: Persist Library/
if: success()
@@ -82,8 +58,5 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: editor_ios_addressables.log
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_ios_addressables.log
path: /tmp/eagle0/editor_ios_addressables.log
retention-days: 5
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
-238
View File
@@ -1,238 +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:
# App Store Connect API Key (replaces deprecated altool with Apple ID)
# Create at: https://appstoreconnect.apple.com/access/api
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
# Write API key to file (xcodebuild needs a file path)
API_KEY_PATH=$(mktemp)
echo "$APP_STORE_CONNECT_API_KEY" > "$API_KEY_PATH"
export APP_STORE_CONNECT_API_KEY_PATH="$API_KEY_PATH"
chmod +x ./ci/github_actions/upload_testflight.sh
./ci/github_actions/upload_testflight.sh "$EAGLE0_BUILD_DIR/archive/eagle0.xcarchive" "$APPLE_TEAM_ID" "$PROFILE_UUID"
# Cleanup
rm -f "$API_KEY_PATH"
- 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
+29 -93
View File
@@ -21,8 +21,6 @@ on:
- "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:
@@ -42,8 +40,6 @@ on:
- "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:
@@ -57,12 +53,6 @@ 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:
runs-on: [self-hosted, macOS, unity-mac]
@@ -71,30 +61,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 +76,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()
@@ -123,7 +97,7 @@ jobs:
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 +126,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 +148,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,17 +159,17 @@ 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
@@ -216,7 +177,7 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: signed-mac-app-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app.zip
path: /tmp/eagle0/eagle0MAC/eagle0.app.zip
retention-days: 1
- name: Archive Build Log
@@ -224,11 +185,8 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: editor_mac.log
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_mac.log
path: /tmp/eagle0/editor_mac.log
retention-days: 5
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
wait-notarization:
needs: build-and-sign
@@ -241,20 +199,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
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,11 +221,11 @@ 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
@@ -275,11 +233,8 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: notarized-mac-app-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app.zip
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 +247,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
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
@@ -324,12 +279,12 @@ jobs:
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"
APP_PATH="/tmp/eagle0/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")
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" \
@@ -337,22 +292,6 @@ jobs:
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 }}
@@ -368,9 +307,6 @@ jobs:
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:
@@ -10,7 +10,6 @@ on:
- 'src/main/resources/net/eagle0/shardok/**'
- 'ci/BUILD.bazel'
- 'MODULE.bazel'
- '.bazelrc'
- '.github/workflows/shardok_arm64_build.yml'
workflow_dispatch:
inputs:
-2
View File
@@ -11,7 +11,6 @@ on:
- 'WORKSPACE'
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.bazelrc'
- '.github/workflows/shardok_build.yml'
pull_request:
paths:
@@ -22,7 +21,6 @@ on:
- 'WORKSPACE'
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.bazelrc'
- '.github/workflows/shardok_build.yml'
permissions:
+7 -51
View File
@@ -18,8 +18,6 @@ on:
- "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:
@@ -39,51 +37,29 @@ on:
- "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:
@@ -100,7 +76,7 @@ jobs:
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'
@@ -125,30 +101,10 @@ jobs:
# 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
retention-days: 5
+1 -5
View File
@@ -92,7 +92,7 @@ bazel run //src/main/cpp/net/eagle0/shardok:shardok-server --compilation_mode=op
bazel test //src/test/... //src/main/go/...
# Component-specific tests
bazel test //src/main/scala/... # Scala Eagle tests
bazel test //src/test/scala/... # Scala Eagle tests
bazel test //src/test/cpp/... # C++ Shardok tests
```
@@ -110,10 +110,6 @@ bazel run gazelle # Update Go build files
1. **If you modified any BUILD.bazel file:** Run `bazel run gazelle` and stage any changes it makes
2. **If you modified C++ or C# files:** Run `clang-format -i` on the modified files
3. **If you modified Scala files:** scalafmt will run automatically via pre-commit hook
4. **ALWAYS run the full test suite for your language changes before pushing:**
- For Scala changes: `bazel test //src/main/scala/...`
- For C++ changes: `bazel test //src/test/cpp/...`
- This catches exhaustive pattern match errors and other compile-time failures that single-target builds miss
The pre-commit hook runs gazelle but only checks if it succeeds - it does NOT verify the BUILD files are in canonical format. The `gazelle_test` will fail if deps are not alphabetically sorted. **Always run gazelle manually after BUILD file changes.**
+33 -119
View File
@@ -1,27 +1,26 @@
module(name = "net_eagle0")
# Version constants
SCALA_VERSION = "3.7.4"
SCALA_VERSION = "3.7.2"
NETTY_VERSION = "4.1.127.Final"
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
#
bazel_dep(name = "bazel_skylib", version = "1.9.0")
bazel_dep(name = "platforms", version = "1.0.0")
bazel_dep(name = "rules_pkg", version = "1.2.0")
#
# Language Support - Scala
#
bazel_dep(name = "rules_scala", version = "7.2.1")
bazel_dep(name = "rules_scala", version = "7.1.1")
scala_config = use_extension(
"@rules_scala//scala/extensions:config.bzl",
@@ -48,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
@@ -68,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
@@ -123,28 +122,15 @@ use_repo(
"com_github_webview_webview_go",
"org_golang_google_grpc",
"org_golang_google_protobuf",
"org_golang_x_sys",
)
#
# Platform Support - Apple/iOS
#
# Note: rules_apple is NOT included in the main workspace due to rules_swift
# version conflicts with grpc. SparklePlugin (which needs rules_apple) is built
# in a separate workspace at sparkle_workspace/ to isolate the conflict.
#
bazel_dep(name = "apple_support", version = "1.23.1", repo_name = "build_bazel_apple_support")
# rules_swift is a transitive dep of grpc and flatbuffers with conflicting versions:
# - grpc 1.76.0.bcr.1 requires rules_swift 3.x (compatibility level 3)
# - flatbuffers requires rules_swift 2.x (compatibility level 2)
# We don't use Swift directly, but we need to force a single version.
# Force 3.x since grpc is more complex and harder to downgrade.
single_version_override(
module_name = "rules_swift",
version = "3.1.2",
)
bazel_dep(name = "apple_support", version = "1.21.1", repo_name = "build_bazel_apple_support")
bazel_dep(name = "rules_apple", version = "4.3.3", repo_name = "build_bazel_rules_apple")
bazel_dep(name = "rules_swift", version = "2.4.0", repo_name = "build_bazel_rules_swift")
# Register Apple CC toolchain for Objective-C compilation
apple_cc_configure = use_extension(
@@ -157,12 +143,10 @@ use_repo(apple_cc_configure, "local_config_apple_cc")
# Protocol Buffers & RPC
#
bazel_dep(name = "protobuf", version = "33.5", repo_name = "com_google_protobuf")
bazel_dep(name = "rules_proto", version = "7.1.0")
bazel_dep(name = "rules_cc", version = "0.2.14")
bazel_dep(name = "grpc", version = "1.74.0")
bazel_dep(name = "grpc-java", version = "1.78.0")
bazel_dep(name = "flatbuffers", version = "25.12.19")
bazel_dep(name = "protobuf", version = "29.2", repo_name = "com_google_protobuf")
bazel_dep(name = "grpc", version = "1.71.0")
bazel_dep(name = "grpc-java", version = "1.71.0")
bazel_dep(name = "flatbuffers", version = "25.9.23")
#
# Testing
@@ -175,24 +159,16 @@ bazel_dep(name = "googletest", version = "1.17.0")
#
bazel_dep(name = "rules_oci", version = "2.2.7")
bazel_dep(name = "aspect_bazel_lib", version = "2.22.5")
bazel_dep(name = "aspect_bazel_lib", version = "2.22.4")
oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
# Base image for Eagle (Java 25 JRE - smaller runtime, no dev tools needed)
# Base image for Eagle (Java 17 JDK - includes jcmd for JFR dumps)
oci.pull(
name = "eclipse_temurin_25_jre",
name = "eclipse_temurin_17",
image = "docker.io/library/eclipse-temurin",
platforms = ["linux/amd64"],
tag = "25-jre",
)
# Base image for JFR sidecar (Java 25 JDK - includes jcmd for JFR dumps)
oci.pull(
name = "eclipse_temurin_25_jdk",
image = "docker.io/library/eclipse-temurin",
platforms = ["linux/amd64"],
tag = "25-jdk",
tag = "17-jdk",
)
# Base image for Shardok (Ubuntu 24.04 for C++ runtime)
@@ -213,14 +189,13 @@ oci.pull(
platforms = ["linux/amd64"],
tag = "3.21",
)
use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_25_jdk", "eclipse_temurin_25_jdk_linux_amd64", "eclipse_temurin_25_jre", "eclipse_temurin_25_jre_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64", "ubuntu_24_04_linux_arm64_v8")
use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_17", "eclipse_temurin_17_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64", "ubuntu_24_04_linux_arm64_v8")
#
# Java/Scala Dependencies
#
bazel_dep(name = "rules_java", version = "9.3.0")
bazel_dep(name = "rules_jvm_external", version = "6.10")
bazel_dep(name = "rules_jvm_external", version = "6.9")
maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")
maven.install(
@@ -282,7 +257,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,
@@ -291,27 +266,6 @@ maven.install(
"https://repo1.maven.org/maven2",
],
)
# Force specific versions for dependencies with conflicts between grpc-java and protobuf
maven.artifact(
artifact = "gson",
force_version = True,
group = "com.google.code.gson",
version = "2.11.0",
)
maven.artifact(
artifact = "error_prone_annotations",
force_version = True,
group = "com.google.errorprone",
version = "2.30.0",
)
maven.artifact(
artifact = "guava",
force_version = True,
group = "com.google.guava",
version = "33.3.1-android",
)
use_repo(maven, "maven", "unpinned_maven")
#
@@ -328,14 +282,7 @@ GTL_SHA = "1969c45dd76eac0dd87e9e2b65cffe358617f4fe1bcd203f72f427742537913a"
http_archive(
name = "gtl",
build_file_content = """
cc_library(
name = "gtl",
hdrs = glob(["include/gtl/*.hpp"]),
includes = ["include"],
visibility = ["//visibility:public"],
)
""",
build_file = "@//external:BUILD.gtl",
sha256 = GTL_SHA,
strip_prefix = "gtl-%s" % GTL_VERSION,
url = "https://github.com/greg7mdp/gtl/archive/refs/tags/v%s.zip" % GTL_VERSION,
@@ -355,8 +302,16 @@ http_archive(
],
)
# Note: Sparkle framework is defined in sparkle_workspace/MODULE.bazel
# (not in main workspace due to rules_swift version conflicts)
# Sparkle framework for macOS auto-updates
SPARKLE_VERSION = "2.6.4"
http_archive(
name = "sparkle",
build_file = "@//external:BUILD.sparkle",
sha256 = "50612a06038abc931f16011d7903b8326a362c1074dabccb718404ce8e585f0b",
strip_prefix = "",
url = "https://github.com/sparkle-project/Sparkle/releases/download/%s/Sparkle-%s.tar.xz" % (SPARKLE_VERSION, SPARKLE_VERSION),
)
# Busybox static binary for Docker health checks (provides nc, wget, etc.)
# Primary: DigitalOcean Spaces (public, reliable)
@@ -390,48 +345,7 @@ LLVM_MINGW_VERSION = "20250305"
http_archive(
name = "llvm_mingw",
build_file_content = """
package(default_visibility = ["//visibility:public"])
filegroup(
name = "all_files",
srcs = glob(["**/*"]),
)
filegroup(
name = "compiler_files",
srcs = glob([
"bin/x86_64-w64-mingw32-*",
"bin/clang*",
"bin/llvm-*",
"bin/lld*",
]),
)
filegroup(
name = "windows_x86_64_sysroot",
srcs = glob([
"x86_64-w64-mingw32/**/*",
"generic-w64-mingw32/include/**/*",
]),
)
filegroup(
name = "linker_files",
srcs = glob([
"bin/x86_64-w64-mingw32-*",
"bin/lld*",
"bin/ld.lld*",
"lib/**/*",
"x86_64-w64-mingw32/lib/**/*",
]),
)
exports_files([
"bin/x86_64-w64-mingw32-clang",
"bin/x86_64-w64-mingw32-clang++",
])
""",
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)],
+4524 -1075
View File
File diff suppressed because it is too large Load Diff
+2 -23
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.)
#
@@ -73,7 +60,7 @@ pkg_tar(
oci_image(
name = "eagle_server_image",
base = "@eclipse_temurin_25_jre_linux_amd64",
base = "@eclipse_temurin_17_linux_amd64",
entrypoint = [
"java",
"-Xmx2g",
@@ -288,7 +275,7 @@ pkg_tar(
oci_image(
name = "jfr_sidecar_image",
# Use JDK base image - we need jcmd to dump JFR recordings
base = "@eclipse_temurin_25_jdk_linux_amd64",
base = "@eclipse_temurin_17_linux_amd64",
entrypoint = ["/app/jfr_server_linux_amd64"],
exposed_ports = ["8081/tcp"],
tars = [
@@ -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",
)
+1 -1
View File
@@ -4,4 +4,4 @@ set -e
set -x
bazel test \
//src/main/scala/...
//src/test/scala/...
-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"
+5 -21
View File
@@ -5,45 +5,29 @@
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}"
. ./ci/unity_version.sh
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"}
LOG_PATH=${1:-"/tmp/eagle0/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
# -buildTarget iOS switches the editor to iOS before running
${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
-nographics \
-batchmode \
-quit \
-executeMethod BuildScript.BuildiOSAddressables \
-buildTarget iOS \
-executeMethod BuildScript.BuildAddressables \
-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/"
+1 -15
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"
@@ -17,8 +16,6 @@ echo "Cleaning up $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 \
@@ -27,14 +24,3 @@ ${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
-buildPath "$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"
+1 -15
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"
@@ -17,8 +16,6 @@ echo "Cleaning up $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 \
@@ -27,14 +24,3 @@ ${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
-buildPath "$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
-102
View File
@@ -1,102 +0,0 @@
#!/usr/bin/env bash
# Upload xcarchive to TestFlight using App Store Connect API Key
# This replaces the deprecated altool which was removed in Xcode 14+
#
# Uses xcodebuild -exportArchive with destination=upload, which is Apple's
# recommended approach for CI/CD pipelines.
set -euxo pipefail
# Ensure xcodebuild uses Xcode.app, not Command Line Tools
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
ARCHIVE_PATH=${1:?Usage: upload_testflight.sh <xcarchive_path> <team_id> <profile_uuid>}
TEAM_ID=${2:?Missing team ID}
PROFILE_UUID=${3:?Missing provisioning profile UUID}
if [ ! -d "$ARCHIVE_PATH" ]; then
echo "Error: xcarchive not found: $ARCHIVE_PATH"
exit 1
fi
echo "Uploading to TestFlight: $ARCHIVE_PATH"
# Requires App Store Connect API Key:
# - APP_STORE_CONNECT_API_KEY_ID: Key ID from App Store Connect
# - APP_STORE_CONNECT_API_ISSUER_ID: Issuer ID from App Store Connect
# - APP_STORE_CONNECT_API_KEY_PATH: Path to .p8 private key file
#
# To create an API key:
# 1. Go to https://appstoreconnect.apple.com/access/api
# 2. Click the + button to create a new key
# 3. Give it a name and Admin or App Manager access
# 4. Download the .p8 file (you can only download it once!)
# 5. Note the Key ID and Issuer ID shown on the page
if [ -z "${APP_STORE_CONNECT_API_KEY_ID:-}" ]; then
echo "Error: APP_STORE_CONNECT_API_KEY_ID environment variable not set"
echo "Create an API key at https://appstoreconnect.apple.com/access/api"
exit 1
fi
if [ -z "${APP_STORE_CONNECT_API_ISSUER_ID:-}" ]; then
echo "Error: APP_STORE_CONNECT_API_ISSUER_ID environment variable not set"
exit 1
fi
if [ -z "${APP_STORE_CONNECT_API_KEY_PATH:-}" ]; then
echo "Error: APP_STORE_CONNECT_API_KEY_PATH environment variable not set"
exit 1
fi
if [ ! -f "$APP_STORE_CONNECT_API_KEY_PATH" ]; then
echo "Error: API key file not found: $APP_STORE_CONNECT_API_KEY_PATH"
exit 1
fi
# Create export options plist with upload destination
EXPORT_OPTIONS_PLIST=$(mktemp)
trap "rm -f $EXPORT_OPTIONS_PLIST" EXIT
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>destination</key>
<string>upload</string>
<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 "Export options:"
cat "$EXPORT_OPTIONS_PLIST"
echo "Uploading to App Store Connect..."
# Use xcodebuild to upload with App Store Connect API authentication
xcodebuild -exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportOptionsPlist "$EXPORT_OPTIONS_PLIST" \
-authenticationKeyPath "$APP_STORE_CONNECT_API_KEY_PATH" \
-authenticationKeyID "$APP_STORE_CONNECT_API_KEY_ID" \
-authenticationKeyIssuerID "$APP_STORE_CONNECT_API_ISSUER_ID"
echo "Upload complete! Check App Store Connect for processing status."
echo "The build should appear in TestFlight within 15-30 minutes after processing."
+1
View File
@@ -0,0 +1 @@
UNITY_VERSION='6000.3.0f1'
-7
View File
@@ -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 (uses eagle0-assets bucket)
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
DO_SPACES_ACCESS_KEY: "${ADMIN_S3_ACCESS_KEY:-}"
DO_SPACES_SECRET_KEY: "${ADMIN_S3_SECRET_KEY:-}"
# No external port - accessed via nginx at admin.eagle0.net
depends_on:
- auth
-242
View File
@@ -1,242 +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`.
**PR Dependencies:**
```
PR 1 (types)
├── PR 2 (client)
└── PR 3 (generation)
```
PR 2 and PR 3 both depend on PR 1, but are independent of each other. They can be developed in parallel after PR 1 is merged, and merged in either order. The key constraint is that PR 3 should not be deployed before PR 2, so clients understand the quest type before the server starts generating 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/main/scala/...
```
3. Verify proto/Scala parity:
```bash
bazel test //src/main/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?
+27 -72
View File
@@ -126,27 +126,6 @@ All 26 tracks have CC licenses with proper attribution:
---
## 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/) |
---
## 3. CC0 / Public Domain Assets
### SimpleFileBrowser Icons
@@ -176,35 +155,26 @@ All 26 tracks have CC licenses with proper attribution:
- `undead_break_control.mp3`
- `undead_grew.wav`
**⚠️ MUST REPLACE (1 remaining):**
**⚠️ MUST REPLACE:**
- ~~`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)
- `runaway.mp3` - licensing issue, no replacement found in purchased assets
**Verified from [Medieval Combat Sounds](https://assetstore.unity.com/packages/audio/sound-fx/medieval-combat-sounds-100465) (Unity Asset Store):**
- `duel_challenged.wav` - **REPLACED** (2026-02-03) with `Weapon Draw Metal 1.wav` from Medieval Combat Sounds
**Presumed from Unity Asset Store purchases (30 files):**
**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`, `failure_horn.mp3`, `fear.mp3`, `fear_failed.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)
### Free Icons
- **Location:** `Assets/free_icons/`
- **Count:** 8 PNG weather icons
- **Status:** Verify "free" means commercially usable
### ~~Terrain Hexes~~ VERIFIED
- **Location:** `Assets/Terrain Hexes/`
@@ -246,31 +216,26 @@ 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. ~~**Clip art images**~~ - **RESOLVED** (2025-01-23): Replaced with game-icons.net CC BY 3.0 icons
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. **Shardok sound effects** - 3 files must be replaced:
- `anybody.mp3` - licensing issue
- `burnination.mp3` - licensing issue
- `runaway.mp3` - licensing issue
### Low Priority (Verify):
Remaining 31 files presumed from Asset Store purchases; 3 verified from Zombie Monster Undead Collection.
3. **Dima Koltsov tracks** - 2 of 5 not verified: `Forest Queen Tale`, `Clouds` (presumed CC BY 4.0 like his other tracks)
3. ~~**Terrain Hexes**~~ - **VERIFIED** (2026-01-23): Confirmed Unity Asset Store purchase
### Already Resolved:
4. ~~**StrategyGameIcons**~~ - **VERIFIED** (2026-01-23): Unity Asset Store purchase (REXARD)
4. ~~**Free Icons**~~ - **RESOLVED** (2026-01-27): All replaced with Asset Store equivalents, folder deleted
5. ~~**Medieval: Victory Theme**~~ - **VERIFIED** (2026-01-23): CC0 Public Domain by RandomMind ([Chosic](https://www.chosic.com/download-audio/28492/))
5. ~~**Terrain Hexes**~~ - **VERIFIED** (2026-01-23): Confirmed Unity Asset Store purchase
6. ~~**Dima Koltsov tracks**~~ - **MOSTLY VERIFIED** (2026-01-23): 3 of 5 confirmed CC BY 4.0 via YouTube. 2 remaining (Forest Queen Tale, Clouds) presumed same license.
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**~~ - **OK** (2026-01-23): Usage complies with Discord brand guidelines for "Login with Discord" button
### Already Safe:
@@ -284,23 +249,13 @@ NuGet packages in `Assets/Packages/` all include LICENSE.TXT files:
## Recommendation
**Remaining before public release:**
Before public release:
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. ~~Replace clip art images (`bridge.png`, `startFire.png`) with properly licensed alternatives~~ **DONE** - see Section 4
2. ~~Verify source of `Assets/Shardok/soundEffects/` MP3s~~ **MOSTLY DONE** - 3 files flagged for replacement, rest presumed Asset Store
3. ~~Verify source of `Assets/Terrain Hexes/`~~ **DONE** - confirmed Asset Store purchase
4. ~~Verify source of `Assets/StrategyGameIcons/`~~ **DONE** - Unity Asset Store (REXARD)
5. ~~Replace Dima Koltsov Audius tracks~~ **MOSTLY DONE** - 3/5 confirmed CC BY 4.0, 2 presumed same
6. ~~Find source of Medieval: Victory Theme or replace~~ **DONE** - CC0 Public Domain by RandomMind
The bulk of your assets (95%+) are properly licensed Asset Store purchases or CC content.
-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
-150
View File
@@ -1,150 +0,0 @@
# rules_apple Workspace Separation
This document explains why `rules_apple` is built in a separate Bazel workspace (`sparkle_workspace/`) and what conditions need to be met before it can be reintegrated into the main workspace.
## Background
The main workspace cannot include `rules_apple` due to transitive dependency conflicts with `rules_swift`. Multiple dependencies require different major versions of `rules_swift`:
| Dependency | rules_swift Version | Compatibility Level |
|------------|---------------------|---------------------|
| grpc | 3.x | 3 |
| flatbuffers | 2.x | 2 |
| rules_apple | 2.x | 2 |
Bzlmod's `single_version_override` can force a single version, but compatibility levels 2 and 3 are incompatible. Forcing `rules_swift` 3.x (required for grpc) breaks `rules_apple` and `flatbuffers`.
## Current Solution
The `SparklePlugin` (the only component requiring `rules_apple`) is built in an isolated workspace:
```
sparkle_workspace/
├── MODULE.bazel # Minimal deps: apple_support, rules_apple, sparkle
├── BUILD.bazel # Builds SparklePlugin.bundle
├── SparklePlugin.m # Objective-C source
├── Info.plist # Bundle metadata
└── external/
└── BUILD.sparkle # Build file for Sparkle framework
```
### How It Works
1. **Build script**: `scripts/build_sparkle_plugin.sh` builds the plugin from the separate workspace
2. **Output**: The built `SparklePlugin.bundle` is placed in `Assets/Plugins/macOS/`
3. **CI integration**: `mac_build.yml` calls `inject_sparkle.sh` to embed Sparkle into the Mac app
The main workspace uses `single_version_override` for `rules_swift` 3.x to satisfy grpc, and includes a comment noting that `rules_apple` is intentionally excluded.
## What SparklePlugin Does
SparklePlugin is a native macOS library that:
- Initializes the [Sparkle](https://sparkle-project.org/) auto-update framework
- Exposes C functions for Unity to call via P/Invoke:
- `SparklePlugin_Initialize`
- `SparklePlugin_CheckForUpdates`
- `SparklePlugin_CheckForUpdatesInBackground`
- `SparklePlugin_IsCheckingForUpdates`
- `SparklePlugin_GetAutomaticallyChecksForUpdates`
- `SparklePlugin_SetAutomaticallyChecksForUpdates`
- `SparklePlugin_IsRunningFromReadOnlyVolume`
- `SparklePlugin_ShowDMGWarning`
## Conditions for Reintegration
To move `rules_apple` back into the main workspace, **all** of the following must be true:
### 1. rules_swift Version Alignment
Check if grpc, flatbuffers, and rules_apple all support the same `rules_swift` major version:
```bash
# Check what rules_swift version each dependency requires
bazel mod graph 2>&1 | grep -A2 "rules_swift"
```
Or check BCR directly:
- https://registry.bazel.build/modules/grpc
- https://registry.bazel.build/modules/flatbuffers
- https://registry.bazel.build/modules/rules_apple
**Test**: After updating versions, verify you can add this to MODULE.bazel without errors:
```python
bazel_dep(name = "rules_apple", version = "X.Y.Z")
```
### 2. Build Test
If the dependency can be added, test that both the main build and SparklePlugin work:
```bash
# Main workspace builds
bazel build //src/main/scala/net/eagle0/eagle:eagle_server
bazel build //src/main/cpp/net/eagle0/shardok:shardok-server
bazel build //ci:eagle_server_image
# SparklePlugin builds (would move to main workspace)
bazel build //sparkle:SparklePlugin # After moving files
```
### 3. Files to Move
When reintegrating, move these from `sparkle_workspace/` to the main workspace:
| Source | Destination |
|--------|-------------|
| `sparkle_workspace/SparklePlugin.m` | `src/main/objc/net/eagle0/sparkle/SparklePlugin.m` |
| `sparkle_workspace/Info.plist` | `src/main/objc/net/eagle0/sparkle/Info.plist` |
| `sparkle_workspace/BUILD.bazel` | `src/main/objc/net/eagle0/sparkle/BUILD.bazel` |
| `sparkle_workspace/external/BUILD.sparkle` | (inline into MODULE.bazel http_archive) |
### 4. MODULE.bazel Changes
Add to the main MODULE.bazel:
```python
bazel_dep(name = "rules_apple", version = "X.Y.Z", repo_name = "build_bazel_rules_apple")
# Sparkle framework
http_archive(
name = "sparkle",
build_file_content = """...""", # Contents from external/BUILD.sparkle
sha256 = "...",
url = "https://github.com/sparkle-project/Sparkle/releases/download/...",
)
```
Remove the `single_version_override` for `rules_swift` if no longer needed.
### 5. Update Build Scripts
- Update `scripts/build_sparkle_plugin.sh` to build from main workspace
- Update `scripts/build_plugins.sh` if it references the separate workspace
- Verify `mac_build.yml` still works
### 6. Cleanup
After successful integration:
```bash
rm -rf sparkle_workspace/
```
Update comments in MODULE.bazel that reference the separate workspace.
## Version History
| Date | Event |
|------|-------|
| 2026-02-04 | Separated sparkle_workspace (PR #5882) to enable Bazel 8 upgrade |
| 2026-02-05 | Upgraded to Bazel 8.5.1 (PR #5883) |
## Monitoring
Periodically check if the upstream dependencies have aligned:
1. **grpc releases**: https://github.com/grpc/grpc/releases
2. **flatbuffers releases**: https://github.com/google/flatbuffers/releases
3. **rules_apple releases**: https://github.com/bazelbuild/rules_apple/releases
4. **BCR updates**: https://registry.bazel.build/
When a new version of any of these is released, check if the `rules_swift` requirements have converged.
+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
+17 -18
View File
@@ -15,11 +15,11 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
- [x] ~~Shardok performance similar to QA~~
- [x] ~~Error logging & alerting~~
- [x] ~~Fix long disconnects on deployments~~
- [x] Fix the Mac installer
- [ ] 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
- [ ] Generatedtext healing
- [ ] Kill outstanding shardok requests when game is deleted
### Other Productionization
@@ -31,34 +31,35 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
- [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)
- [ ] "Message of the day"
- [ ] Support plan
### Alpha Tester Support
- [x] Feedback channel (Discord server)
- [x] Bug report form in Unity client (Settings > Report Bug, sends to Discord webhook)
- [ ] Feedback channel (Discord server? Bug report form?)
- [ ] Crash reporting from Unity client
- [ ] 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)
- [ ] Document and make available licenses for art & music
- [ ] Required open source disclosures
- [ ] Audit assets for anything we don't have rights to and replace it
- [ ] Replace heroes that are based on real 20th or 21st century people or IP
- [ ] Privacy policy (collecting accounts, OAuth data, gameplay data)
- [ ] Terms of service (basic liability protection)
- [ ] Data deletion capability (user requests account removal)
### Basic Gameplay
- [ ] Tutorial
- [x] In the Your Warlord panel, say what the profession is
- [ ] 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
- [ ] Running low on food
- [x] ~~Time to swear brotherhood~~
- [x] ~~When you get large, or~~
- [x] ~~When you get a good candidate~~
@@ -66,8 +67,6 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
- [ ] 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)
@@ -76,4 +75,4 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
## Nice to have
- [x] "What's new" changelog (fetch JSON, show entries since last launch)
<!-- Add nice-to-have items here as they come up -->
+1 -1
View File
@@ -58,7 +58,7 @@ Based on conversion attempt of `ResolveTruceOfferCommand` (see [PR #4379](https:
**Before creating any PR:**
-`bazel build //src/main/scala/net/eagle0/eagle:eagle_server` succeeds
-`bazel test //src/main/scala/... --keep_going` passes (or doesn't introduce new failures)
-`bazel test //src/test/scala/... --keep_going` passes (or doesn't introduce new failures)
- ✅ All BUILD dependencies are correctly specified
- ✅ Scalafmt and other linters pass
-113
View File
@@ -1,113 +0,0 @@
# AI Quest Completion Behavior
This document describes which quests the AI attempts to complete proactively to recruit unaffiliated heroes.
## Overview
The AI attempts to complete quests via `FulfillQuestsCommandSelector`, which is invoked by `MidGameAIClient.chosenFulfillEasyQuestsCommand`. The AI only considers quests from unaffiliated heroes in provinces ruled by a faction leader.
## Quests the AI Actively Completes
### Diplomacy Quests
| Quest | Handler | Conditions |
|-------|---------|------------|
| `AllianceQuest` | `AllianceQuestCommandChooser` | Target faction must meet trust conditions for alliance and not already be in an alliance |
| `TruceWithFactionQuest` | `TruceWithFactionQuestCommandChooser` | Target faction must meet trust conditions for truce |
| `TruceCountQuest` | `TruceCountQuestCommandChooser` | Picks a random faction that meets trust conditions and isn't already in a truce/alliance |
### Resource Giving Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `AlmsToProvinceQuest` | `AlmsToProvinceQuestCommandChooser` | Gives food to the specified province |
| `AlmsAcrossRealmQuest` | `AlmsAcrossRealmQuestCommandChooser` | Gives food from the province with the largest surplus |
| `GiveToHeroesInProvinceQuest` | `GiveToHeroesInProvinceQuestCommandChooser` | Gives gold to the hero with the lowest loyalty in the specified province |
| `GiveToHeroesAcrossRealmQuest` | `GiveToHeroesAcrossRealmQuestCommandChooser` | Gives gold from the province with the most gold available |
### Province Development Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `ImproveAgricultureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Agriculture type |
| `ImproveEconomyQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Economy type |
| `ImproveInfrastructureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Infrastructure type |
### Other Quests
| Quest | Handler | Conditions |
|-------|---------|------------|
| `DismissSpecificVassalQuest` | `DismissSpecificVassalCommandChooser` | Only if province has more than 2 heroes AND the unaffiliated hero's power >= target hero's power * `RequiredPowerMultiplierForDismiss` |
## Quests the AI Does Not Attempt to Complete
The following quests have no handler in `FulfillQuestsCommandSelector` and must be completed naturally through gameplay:
### Combat/Military Quests
- `DefeatFactionQuest` - Defeat a specific faction
- `GrandArmyQuest` - Accumulate a large number of troops
- `UpgradeBattalionQuest` - Upgrade a battalion to minimum armament/training
- `WinBattleOutnumberedQuest` - Win a battle while outnumbered
- `WinBattlesQuest` - Win a number of battles
- `RescueImprisonedLeaderQuest` - Rescue an imprisoned leader from another faction
### Expansion Quests
- `ExpandToProvincesQuest` - Expand to control a certain number of provinces
- `SpecificExpansionQuest` - Conquer a specific province
- `BorderSecurityQuest` - Have troops in a border province
### Prisoner Quests
- `ExecutePrisonerQuest` - Execute a specific prisoner
- `ExilePrisonerQuest` - Exile a specific prisoner
- `ReleasePrisonerQuest` - Release a specific prisoner
- `ReturnPrisonerQuest` - Return a prisoner to their faction
- `ReleaseAllPrisonersQuest` - Release all prisoners
### Province Order Quests
- `DevelopProvincesQuest` - Maintain provinces in Develop order for months
- `MobilizeProvincesQuest` - Maintain provinces in Mobilize order for months
- `RestProvinceQuest` - Use the Rest command in a specific province
### Reconnaissance Quests
- `ReconProvincesQuest` - Reconnoiter a number of provinces
- `ReconSpecificProvincesQuest` - Reconnoiter specific provinces
### Economic Quests
- `TotalDevelopmentQuest` - Achieve total development level in a province
- `WealthQuest` - Accumulate gold and food
- `SpendOnFeastsQuest` - Spend gold on feasts
- `SendSuppliesQuest` - Send food to a specific province
- `RepairDevastationQuest` - Repair devastation
### Special Event Quests
- `SuppressRiotByForceQuest` - Suppress a riot by force
- `FightBeastsAloneQuest` - Fight beasts alone
- `StartBlizzardQuest` - Start a blizzard in a province
- `StartEpidemicQuest` - Start an epidemic in a province
- `ApprehendOutlawQuest` - Apprehend an outlaw hero
### Miscellaneous
- `BattalionDiversityQuest` - Have diverse battalion types
- `SwearBrotherhoodWithHeroQuest` - Swear brotherhood with a specific hero
- `BetrayAllyQuest` - Betray an allied faction
## Implementation Details
The quest completion logic is located in:
- `FulfillQuestsCommandSelector.scala` - Main entry point, iterates through choosers
- `quest_command_selectors/` - Individual quest handlers
Each chooser extends either:
- `QuestCommandChooser` - For quests requiring randomness
- `DeterministicQuestCommandChooser` - For quests with deterministic command selection
The AI prioritizes quests in the order they appear in `FulfillQuestsCommandSelector.choosers`:
1. Alliance
2. TruceWithFaction
3. Improve (Agriculture/Economy/Infrastructure)
4. AlmsToProvince
5. GiveToHeroesInProvince
6. AlmsAcrossRealm
7. GiveToHeroesAcrossRealm
8. TruceCount
9. DismissSpecificVassal
+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
-5
View File
@@ -1,5 +0,0 @@
# Export BUILD files for use with http_archive build_file attribute
exports_files([
"BUILD.gtl",
"BUILD.llvm_mingw",
])
-1
View File
@@ -12,7 +12,6 @@ require (
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
)
-2
View File
@@ -43,8 +43,6 @@ 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=
+162 -466
View File
File diff suppressed because it is too large Load Diff
+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;
}
}
}
+3 -8
View File
@@ -2,9 +2,6 @@
#
# Build the SparklePlugin native library for Unity using Bazel
#
# The SparklePlugin is built in a separate Bazel workspace (sparkle_workspace/)
# to avoid rules_swift version conflicts between grpc and rules_apple.
#
# Usage: build_sparkle_plugin.sh [output_dir]
set -euo pipefail
@@ -14,14 +11,12 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
OUTPUT_DIR="${1:-$PROJECT_ROOT/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/macOS}"
echo "=== Building SparklePlugin with Bazel (from sparkle_workspace) ==="
echo "=== Building SparklePlugin with Bazel ==="
# Build in the separate sparkle_workspace to avoid dependency conflicts
cd "$PROJECT_ROOT/sparkle_workspace"
bazel build //:SparklePlugin
bazel build --config=mactools //src/main/objc/net/eagle0/sparkle:SparklePlugin
# Get the zip path from bazel
ZIP_PATH=$(bazel cquery --output=files //:SparklePlugin 2>/dev/null)
ZIP_PATH=$(bazel cquery --config=mactools --output=files //src/main/objc/net/eagle0/sparkle:SparklePlugin 2>/dev/null)
echo "=== Extracting SparklePlugin.bundle ==="
mkdir -p "$OUTPUT_DIR"
+5 -26
View File
@@ -22,31 +22,11 @@ NC='\033[0m' # No Color
MODE="${1:-check}"
EXIT_CODE=0
# Cache for expensive bazel query results
LIBRARY_DEPS_CACHE=""
MAIN_DEPS_CACHE=""
# Get deps of library/ (cached)
get_library_deps() {
if [ -z "$LIBRARY_DEPS_CACHE" ]; then
LIBRARY_DEPS_CACHE=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...)' 2>/dev/null || true)
fi
echo "$LIBRARY_DEPS_CACHE"
}
# Get deps of src/main (cached)
get_main_deps() {
if [ -z "$MAIN_DEPS_CACHE" ]; then
MAIN_DEPS_CACHE=$(bazel query 'deps(//src/main/...)' 2>/dev/null || true)
fi
echo "$MAIN_DEPS_CACHE"
}
# 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=$(get_main_deps | grep "^//src/test/" || true)
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}"
@@ -63,7 +43,7 @@ check_main_depends_on_test() {
check_library_depends_on_scala_proto() {
echo -e "${YELLOW}Checking: library/ should not depend on Scala proto types...${NC}"
violations=$(get_library_deps | grep "^//src/main/protobuf/.*_scala_proto$" || true)
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
@@ -85,7 +65,7 @@ check_library_depends_on_scala_proto() {
check_library_depends_on_proto_converters() {
echo -e "${YELLOW}Checking: library/ should not depend on proto_converters...${NC}"
violations=$(get_library_deps | grep "^//src/main/scala/net/eagle0/eagle/model/proto_converters/" || true)
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 ' ')
@@ -105,8 +85,7 @@ check_library_depends_on_proto_converters() {
count_proto_deps() {
echo -e "${YELLOW}=== Proto dependency counts ===${NC}"
library_deps=$(get_library_deps)
scala_proto_results=$(echo "$library_deps" | grep "^//src/main/protobuf/.*_scala_proto$" || true)
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
@@ -115,7 +94,7 @@ count_proto_deps() {
echo "library/ Scala proto deps: $scala_proto_count"
# C++/Go proto deps are expected (map generation tools)
all_proto_count=$(echo "$library_deps" | grep -c "^//src/main/protobuf/" || echo "0")
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"
}
+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
+1 -13
View File
@@ -317,23 +317,11 @@ main() {
echo "JFR_SIDECAR_ADDR=jfr-sidecar:8081" >> "${env_file}"
fi
# Restart admin to pick up new .env and ensure latest image
# Restart admin to pick up new .env
# Use --no-deps to prevent cascading to auth (which has secrets not available here)
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"
# Clean up old instance
log_info "Cleaning up old eagle-${active}..."
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${active}" 2>/dev/null || true
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
curl -L "https://docs.google.com/spreadsheets/d/1DHEsiv4cY4gE6AX3sVH82K__mpBD1aznIYCQwQxA_F0/export?gid=0&format=tsv" | tr -d '\r' > /tmp/names.tsv
bazel run //src/main/scala/net/eagle0/util:name_list_checker -- /tmp/names.tsv > /dev/null
bazel run //src/main/scala/net/eagle0/util:name_list_checker -- /tmp/names.tsv > src/main/resources/net/eagle0/names.tsv
bazel run //src/main/scala/net/eagle0/util:name_list_json_maker -- /tmp/names.tsv > src/main/resources/net/eagle0/names.json
curl -L "https://docs.google.com/spreadsheets/d/1NhvG73HKyVE36yGpkV2oJiSIXoNqQOYTr5ArLnucYL0/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/shardok/battalionTypes.tsv
curl -L "https://docs.google.com/spreadsheets/d/1pNWiyxIks2wJ1v7jRLFD24zrKHG2AfhC-nkWmQKQGN4/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/eagle/heroes.tsv
-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
View File
@@ -1 +0,0 @@
7.7.1
-2
View File
@@ -1,2 +0,0 @@
# Bazel symlinks
bazel-*
-28
View File
@@ -1,28 +0,0 @@
module(name = "sparkle_workspace")
# Minimal dependencies for building SparklePlugin
# This workspace is isolated from the main workspace to avoid
# rules_swift version conflicts between grpc and rules_apple
bazel_dep(name = "apple_support", version = "1.21.1", repo_name = "build_bazel_apple_support")
bazel_dep(name = "rules_apple", version = "4.3.3", repo_name = "build_bazel_rules_apple")
# Register Apple CC toolchain for Objective-C compilation
apple_cc_configure = use_extension(
"@build_bazel_apple_support//crosstool:setup.bzl",
"apple_cc_configure_extension",
)
use_repo(apple_cc_configure, "local_config_apple_cc")
# Sparkle framework for macOS auto-updates
http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
SPARKLE_VERSION = "2.6.4"
http_archive(
name = "sparkle",
build_file = "//:external/BUILD.sparkle",
sha256 = "50612a06038abc931f16011d7903b8326a362c1074dabccb718404ce8e585f0b",
strip_prefix = "",
url = "https://github.com/sparkle-project/Sparkle/releases/download/%s/Sparkle-%s.tar.xz" % (SPARKLE_VERSION, SPARKLE_VERSION),
)
-451
View File
@@ -1,451 +0,0 @@
{
"lockFileVersion": 13,
"registryFileHashes": {
"https://bcr.bazel.build/bazel_registry.json": "8a28e4aff06ee60aed2a8c281907fb8bcbf3b753c91fb5a5c57da3215d5b3497",
"https://bcr.bazel.build/modules/abseil-cpp/20210324.2/MODULE.bazel": "7cd0312e064fde87c8d1cd79ba06c876bd23630c83466e9500321be55c96ace2",
"https://bcr.bazel.build/modules/abseil-cpp/20211102.0/MODULE.bazel": "70390338f7a5106231d20620712f7cccb659cd0e9d073d1991c038eb9fc57589",
"https://bcr.bazel.build/modules/abseil-cpp/20230125.1/MODULE.bazel": "89047429cb0207707b2dface14ba7f8df85273d484c2572755be4bab7ce9c3a0",
"https://bcr.bazel.build/modules/abseil-cpp/20230802.0.bcr.1/MODULE.bazel": "1c8cec495288dccd14fdae6e3f95f772c1c91857047a098fad772034264cc8cb",
"https://bcr.bazel.build/modules/abseil-cpp/20230802.0/MODULE.bazel": "d253ae36a8bd9ee3c5955384096ccb6baf16a1b1e93e858370da0a3b94f77c16",
"https://bcr.bazel.build/modules/abseil-cpp/20230802.1/MODULE.bazel": "fa92e2eb41a04df73cdabeec37107316f7e5272650f81d6cc096418fe647b915",
"https://bcr.bazel.build/modules/abseil-cpp/20240116.1/MODULE.bazel": "37bcdb4440fbb61df6a1c296ae01b327f19e9bb521f9b8e26ec854b6f97309ed",
"https://bcr.bazel.build/modules/abseil-cpp/20240116.1/source.json": "9be551b8d4e3ef76875c0d744b5d6a504a27e3ae67bc6b28f46415fd2d2957da",
"https://bcr.bazel.build/modules/apple_support/1.11.1/MODULE.bazel": "1843d7cd8a58369a444fc6000e7304425fba600ff641592161d9f15b179fb896",
"https://bcr.bazel.build/modules/apple_support/1.15.1/MODULE.bazel": "a0556fefca0b1bb2de8567b8827518f94db6a6e7e7d632b4c48dc5f865bc7c85",
"https://bcr.bazel.build/modules/apple_support/1.21.1/MODULE.bazel": "5809fa3efab15d1f3c3c635af6974044bac8a4919c62238cce06acee8a8c11f1",
"https://bcr.bazel.build/modules/apple_support/1.23.1/MODULE.bazel": "53763fed456a968cf919b3240427cf3a9d5481ec5466abc9d5dc51bc70087442",
"https://bcr.bazel.build/modules/apple_support/1.23.1/source.json": "d888b44312eb0ad2c21a91d026753f330caa48a25c9b2102fae75eb2b0dcfdd2",
"https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd",
"https://bcr.bazel.build/modules/bazel_features/1.11.0/MODULE.bazel": "f9382337dd5a474c3b7d334c2f83e50b6eaedc284253334cf823044a26de03e8",
"https://bcr.bazel.build/modules/bazel_features/1.15.0/MODULE.bazel": "d38ff6e517149dc509406aca0db3ad1efdd890a85e049585b7234d04238e2a4d",
"https://bcr.bazel.build/modules/bazel_features/1.17.0/MODULE.bazel": "039de32d21b816b47bd42c778e0454217e9c9caac4a3cf8e15c7231ee3ddee4d",
"https://bcr.bazel.build/modules/bazel_features/1.18.0/MODULE.bazel": "1be0ae2557ab3a72a57aeb31b29be347bcdc5d2b1eb1e70f39e3851a7e97041a",
"https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58",
"https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b",
"https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65",
"https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d",
"https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9",
"https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87",
"https://bcr.bazel.build/modules/bazel_features/1.30.0/source.json": "b07e17f067fe4f69f90b03b36ef1e08fe0d1f3cac254c1241a1818773e3423bc",
"https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7",
"https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8",
"https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e",
"https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686",
"https://bcr.bazel.build/modules/bazel_skylib/1.2.1/MODULE.bazel": "f35baf9da0efe45fa3da1696ae906eea3d615ad41e2e3def4aeb4e8bc0ef9a7a",
"https://bcr.bazel.build/modules/bazel_skylib/1.3.0/MODULE.bazel": "20228b92868bf5cfc41bda7afc8a8ba2a543201851de39d990ec957b513579c5",
"https://bcr.bazel.build/modules/bazel_skylib/1.4.1/MODULE.bazel": "a0dcb779424be33100dcae821e9e27e4f2901d9dfd5333efe5ac6a8d7ab75e1d",
"https://bcr.bazel.build/modules/bazel_skylib/1.4.2/MODULE.bazel": "3bd40978e7a1fac911d5989e6b09d8f64921865a45822d8b09e815eaa726a651",
"https://bcr.bazel.build/modules/bazel_skylib/1.5.0/MODULE.bazel": "32880f5e2945ce6a03d1fbd588e9198c0a959bb42297b2cfaf1685b7bc32e138",
"https://bcr.bazel.build/modules/bazel_skylib/1.6.1/MODULE.bazel": "8fdee2dbaace6c252131c00e1de4b165dc65af02ea278476187765e1a617b917",
"https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d",
"https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b",
"https://bcr.bazel.build/modules/bazel_skylib/1.7.1/source.json": "f121b43eeefc7c29efbd51b83d08631e2347297c95aac9764a701f2a6a2bb953",
"https://bcr.bazel.build/modules/buildozer/7.1.2/MODULE.bazel": "2e8dd40ede9c454042645fd8d8d0cd1527966aa5c919de86661e62953cd73d84",
"https://bcr.bazel.build/modules/buildozer/7.1.2/source.json": "c9028a501d2db85793a6996205c8de120944f50a0d570438fcae0457a5f9d1f8",
"https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb",
"https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4",
"https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6",
"https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/source.json": "41e9e129f80d8c8bf103a7acc337b76e54fad1214ac0a7084bf24f4cd924b8b4",
"https://bcr.bazel.build/modules/googletest/1.14.0/MODULE.bazel": "cfbcbf3e6eac06ef9d85900f64424708cc08687d1b527f0ef65aa7517af8118f",
"https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075",
"https://bcr.bazel.build/modules/jsoncpp/1.9.5/source.json": "4108ee5085dd2885a341c7fab149429db457b3169b86eb081fa245eadf69169d",
"https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902",
"https://bcr.bazel.build/modules/nlohmann_json/3.6.1/MODULE.bazel": "6f7b417dcc794d9add9e556673ad25cb3ba835224290f4f848f8e2db1e1fca74",
"https://bcr.bazel.build/modules/nlohmann_json/3.6.1/source.json": "f448c6e8963fdfa7eb831457df83ad63d3d6355018f6574fb017e8169deb43a9",
"https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5",
"https://bcr.bazel.build/modules/platforms/0.0.11/MODULE.bazel": "0daefc49732e227caa8bfa834d65dc52e8cc18a2faf80df25e8caea151a9413f",
"https://bcr.bazel.build/modules/platforms/0.0.11/source.json": "f7e188b79ebedebfe75e9e1d098b8845226c7992b307e28e1496f23112e8fc29",
"https://bcr.bazel.build/modules/platforms/0.0.4/MODULE.bazel": "9b328e31ee156f53f3c416a64f8491f7eb731742655a47c9eec4703a71644aee",
"https://bcr.bazel.build/modules/platforms/0.0.5/MODULE.bazel": "5733b54ea419d5eaf7997054bb55f6a1d0b5ff8aedf0176fef9eea44f3acda37",
"https://bcr.bazel.build/modules/platforms/0.0.6/MODULE.bazel": "ad6eeef431dc52aefd2d77ed20a4b353f8ebf0f4ecdd26a807d2da5aa8cd0615",
"https://bcr.bazel.build/modules/platforms/0.0.7/MODULE.bazel": "72fd4a0ede9ee5c021f6a8dd92b503e089f46c227ba2813ff183b71616034814",
"https://bcr.bazel.build/modules/platforms/0.0.8/MODULE.bazel": "9f142c03e348f6d263719f5074b21ef3adf0b139ee4c5133e2aa35664da9eb2d",
"https://bcr.bazel.build/modules/platforms/0.0.9/MODULE.bazel": "4a87a60c927b56ddd67db50c89acaa62f4ce2a1d2149ccb63ffd871d5ce29ebc",
"https://bcr.bazel.build/modules/protobuf/21.7/MODULE.bazel": "a5a29bb89544f9b97edce05642fac225a808b5b7be74038ea3640fae2f8e66a7",
"https://bcr.bazel.build/modules/protobuf/27.0/MODULE.bazel": "7873b60be88844a0a1d8f80b9d5d20cfbd8495a689b8763e76c6372998d3f64c",
"https://bcr.bazel.build/modules/protobuf/29.0-rc2/MODULE.bazel": "6241d35983510143049943fc0d57937937122baf1b287862f9dc8590fc4c37df",
"https://bcr.bazel.build/modules/protobuf/29.0-rc3/MODULE.bazel": "33c2dfa286578573afc55a7acaea3cada4122b9631007c594bf0729f41c8de92",
"https://bcr.bazel.build/modules/protobuf/29.0-rc3/source.json": "c16a6488fb279ef578da7098e605082d72ed85fc8d843eaae81e7d27d0f4625d",
"https://bcr.bazel.build/modules/protobuf/3.19.0/MODULE.bazel": "6b5fbb433f760a99a22b18b6850ed5784ef0e9928a72668b66e4d7ccd47db9b0",
"https://bcr.bazel.build/modules/protobuf/3.19.6/MODULE.bazel": "9233edc5e1f2ee276a60de3eaa47ac4132302ef9643238f23128fea53ea12858",
"https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e",
"https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/source.json": "be4789e951dd5301282729fe3d4938995dc4c1a81c2ff150afc9f1b0504c6022",
"https://bcr.bazel.build/modules/re2/2023-09-01/MODULE.bazel": "cb3d511531b16cfc78a225a9e2136007a48cf8a677e4264baeab57fe78a80206",
"https://bcr.bazel.build/modules/re2/2023-09-01/source.json": "e044ce89c2883cd957a2969a43e79f7752f9656f6b20050b62f90ede21ec6eb4",
"https://bcr.bazel.build/modules/rules_android/0.1.1/MODULE.bazel": "48809ab0091b07ad0182defb787c4c5328bd3a278938415c00a7b69b50c4d3a8",
"https://bcr.bazel.build/modules/rules_android/0.1.1/source.json": "e6986b41626ee10bdc864937ffb6d6bf275bb5b9c65120e6137d56e6331f089e",
"https://bcr.bazel.build/modules/rules_apple/4.3.3/MODULE.bazel": "c5c2c4adeeac5f3f2f9b7f16abfa8be7ffefa596171d0d92bed4cae9ade0a498",
"https://bcr.bazel.build/modules/rules_apple/4.3.3/source.json": "3cb1d69c8243ffcc42ecbf84ae8b9cccd7b1e2f091b0aee5a3e9c9a45267f312",
"https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647",
"https://bcr.bazel.build/modules/rules_cc/0.0.10/MODULE.bazel": "ec1705118f7eaedd6e118508d3d26deba2a4e76476ada7e0e3965211be012002",
"https://bcr.bazel.build/modules/rules_cc/0.0.11/MODULE.bazel": "9f249c5624a4788067b96b8b896be10c7e8b4375dc46f6d8e1e51100113e0992",
"https://bcr.bazel.build/modules/rules_cc/0.0.13/MODULE.bazel": "0e8529ed7b323dad0775ff924d2ae5af7640b23553dfcd4d34344c7e7a867191",
"https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc",
"https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87",
"https://bcr.bazel.build/modules/rules_cc/0.0.2/MODULE.bazel": "6915987c90970493ab97393024c156ea8fb9f3bea953b2f3ec05c34f19b5695c",
"https://bcr.bazel.build/modules/rules_cc/0.0.6/MODULE.bazel": "abf360251023dfe3efcef65ab9d56beefa8394d4176dd29529750e1c57eaa33f",
"https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e",
"https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5",
"https://bcr.bazel.build/modules/rules_cc/0.1.2/MODULE.bazel": "557ddc3a96858ec0d465a87c0a931054d7dcfd6583af2c7ed3baf494407fd8d0",
"https://bcr.bazel.build/modules/rules_cc/0.1.2/source.json": "53fcb09b5816c83ca60d9d7493faf3bfaf410dfc2f15deb52d6ddd146b8d43f0",
"https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6",
"https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8",
"https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/source.json": "c8b1e2c717646f1702290959a3302a178fb639d987ab61d548105019f11e527e",
"https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74",
"https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86",
"https://bcr.bazel.build/modules/rules_java/6.5.2/MODULE.bazel": "1d440d262d0e08453fa0c4d8f699ba81609ed0e9a9a0f02cd10b3e7942e61e31",
"https://bcr.bazel.build/modules/rules_java/7.10.0/MODULE.bazel": "530c3beb3067e870561739f1144329a21c851ff771cd752a49e06e3dc9c2e71a",
"https://bcr.bazel.build/modules/rules_java/7.12.2/MODULE.bazel": "579c505165ee757a4280ef83cda0150eea193eed3bef50b1004ba88b99da6de6",
"https://bcr.bazel.build/modules/rules_java/7.2.0/MODULE.bazel": "06c0334c9be61e6cef2c8c84a7800cef502063269a5af25ceb100b192453d4ab",
"https://bcr.bazel.build/modules/rules_java/7.6.1/MODULE.bazel": "2f14b7e8a1aa2f67ae92bc69d1ec0fa8d9f827c4e17ff5e5f02e91caa3b2d0fe",
"https://bcr.bazel.build/modules/rules_java/7.6.5/MODULE.bazel": "481164be5e02e4cab6e77a36927683263be56b7e36fef918b458d7a8a1ebadb1",
"https://bcr.bazel.build/modules/rules_java/8.3.2/MODULE.bazel": "7336d5511ad5af0b8615fdc7477535a2e4e723a357b6713af439fe8cf0195017",
"https://bcr.bazel.build/modules/rules_java/8.5.1/MODULE.bazel": "d8a9e38cc5228881f7055a6079f6f7821a073df3744d441978e7a43e20226939",
"https://bcr.bazel.build/modules/rules_java/8.5.1/source.json": "db1a77d81b059e0f84985db67a22f3f579a529a86b7997605be3d214a0abe38e",
"https://bcr.bazel.build/modules/rules_jvm_external/4.4.2/MODULE.bazel": "a56b85e418c83eb1839819f0b515c431010160383306d13ec21959ac412d2fe7",
"https://bcr.bazel.build/modules/rules_jvm_external/5.1/MODULE.bazel": "33f6f999e03183f7d088c9be518a63467dfd0be94a11d0055fe2d210f89aa909",
"https://bcr.bazel.build/modules/rules_jvm_external/5.2/MODULE.bazel": "d9351ba35217ad0de03816ef3ed63f89d411349353077348a45348b096615036",
"https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0",
"https://bcr.bazel.build/modules/rules_jvm_external/6.3/source.json": "6f5f5a5a4419ae4e37c35a5bb0a6ae657ed40b7abc5a5189111b47fcebe43197",
"https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3",
"https://bcr.bazel.build/modules/rules_kotlin/1.9.6/source.json": "2faa4794364282db7c06600b7e5e34867a564ae91bda7cae7c29c64e9466b7d5",
"https://bcr.bazel.build/modules/rules_license/0.0.3/MODULE.bazel": "627e9ab0247f7d1e05736b59dbb1b6871373de5ad31c3011880b4133cafd4bd0",
"https://bcr.bazel.build/modules/rules_license/0.0.7/MODULE.bazel": "088fbeb0b6a419005b89cf93fe62d9517c0a2b8bb56af3244af65ecfe37e7d5d",
"https://bcr.bazel.build/modules/rules_license/1.0.0/MODULE.bazel": "a7fda60eefdf3d8c827262ba499957e4df06f659330bbe6cdbdb975b768bb65c",
"https://bcr.bazel.build/modules/rules_license/1.0.0/source.json": "a52c89e54cc311196e478f8382df91c15f7a2bfdf4c6cd0e2675cc2ff0b56efb",
"https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc",
"https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff",
"https://bcr.bazel.build/modules/rules_pkg/1.0.1/source.json": "bd82e5d7b9ce2d31e380dd9f50c111d678c3bdaca190cb76b0e1c71b05e1ba8a",
"https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06",
"https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7",
"https://bcr.bazel.build/modules/rules_proto/6.0.2/MODULE.bazel": "ce916b775a62b90b61888052a416ccdda405212b6aaeb39522f7dc53431a5e73",
"https://bcr.bazel.build/modules/rules_proto/6.0.2/source.json": "17a2e195f56cb28d6bbf763e49973d13890487c6945311ed141e196fb660426d",
"https://bcr.bazel.build/modules/rules_python/0.10.2/MODULE.bazel": "cc82bc96f2997baa545ab3ce73f196d040ffb8756fd2d66125a530031cd90e5f",
"https://bcr.bazel.build/modules/rules_python/0.22.1/MODULE.bazel": "26114f0c0b5e93018c0c066d6673f1a2c3737c7e90af95eff30cfee38d0bbac7",
"https://bcr.bazel.build/modules/rules_python/0.23.1/MODULE.bazel": "49ffccf0511cb8414de28321f5fcf2a31312b47c40cc21577144b7447f2bf300",
"https://bcr.bazel.build/modules/rules_python/0.25.0/MODULE.bazel": "72f1506841c920a1afec76975b35312410eea3aa7b63267436bfb1dd91d2d382",
"https://bcr.bazel.build/modules/rules_python/0.28.0/MODULE.bazel": "cba2573d870babc976664a912539b320cbaa7114cd3e8f053c720171cde331ed",
"https://bcr.bazel.build/modules/rules_python/0.31.0/MODULE.bazel": "93a43dc47ee570e6ec9f5779b2e64c1476a6ce921c48cc9a1678a91dd5f8fd58",
"https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c",
"https://bcr.bazel.build/modules/rules_python/1.3.0/MODULE.bazel": "8361d57eafb67c09b75bf4bbe6be360e1b8f4f18118ab48037f2bd50aa2ccb13",
"https://bcr.bazel.build/modules/rules_python/1.3.0/source.json": "25932f917cd279c7baefa6cb1d3fa8750a7a29de522024449b19af6eab51f4a0",
"https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c",
"https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b",
"https://bcr.bazel.build/modules/rules_shell/0.3.0/source.json": "c55ed591aa5009401ddf80ded9762ac32c358d2517ee7820be981e2de9756cf3",
"https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca",
"https://bcr.bazel.build/modules/rules_swift/2.4.0/MODULE.bazel": "1639617eb1ede28d774d967a738b4a68b0accb40650beadb57c21846beab5efd",
"https://bcr.bazel.build/modules/rules_swift/2.4.0/source.json": "a6577f57f9febbdc015a01f2a8f3487422032f134d6c61d18ed8e8ca3b9acc7c",
"https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8",
"https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c",
"https://bcr.bazel.build/modules/stardoc/0.7.0/MODULE.bazel": "05e3d6d30c099b6770e97da986c53bd31844d7f13d41412480ea265ac9e8079c",
"https://bcr.bazel.build/modules/stardoc/0.7.2/MODULE.bazel": "fc152419aa2ea0f51c29583fab1e8c99ddefd5b3778421845606ee628629e0e5",
"https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216",
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91",
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/source.json": "32bd87e5f4d7acc57c5b2ff7c325ae3061d5e242c0c4c214ae87e0f1c13e54cb",
"https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43",
"https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0",
"https://bcr.bazel.build/modules/zlib/1.2.12/MODULE.bazel": "3b1a8834ada2a883674be8cbd36ede1b6ec481477ada359cd2d3ddc562340b27",
"https://bcr.bazel.build/modules/zlib/1.3.1.bcr.3/MODULE.bazel": "af322bc08976524477c79d1e45e241b6efbeb918c497e8840b8ab116802dda79",
"https://bcr.bazel.build/modules/zlib/1.3.1.bcr.3/source.json": "2be409ac3c7601245958cd4fcdff4288be79ed23bd690b4b951f500d54ee6e7d",
"https://bcr.bazel.build/modules/zlib/1.3.1/MODULE.bazel": "751c9940dcfe869f5f7274e1295422a34623555916eb98c174c1e945594bf198"
},
"selectedYankedVersions": {},
"moduleExtensions": {
"@@rules_java~//java:rules_java_deps.bzl%compatibility_proxy": {
"general": {
"bzlTransitiveDigest": "C4xqrMy1wN4iuTN6Z2eCm94S5XingHhD6uwrIXvCxVI=",
"usagesDigest": "pwHZ+26iLgQdwvdZeA5wnAjKnNI3y6XO2VbhOTeo5h8=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"compatibility_proxy": {
"bzlFile": "@@rules_java~//java:rules_java_deps.bzl",
"ruleClassName": "_compatibility_proxy_repo_rule",
"attributes": {}
}
},
"recordedRepoMappingEntries": [
[
"rules_java~",
"bazel_tools",
"bazel_tools"
]
]
}
},
"@@rules_kotlin~//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": {
"general": {
"bzlTransitiveDigest": "eecmTsmdIQveoA97hPtH3/Ej/kugbdCI24bhXIXaly8=",
"usagesDigest": "aJF6fLy82rR95Ff5CZPAqxNoFgOMLMN5ImfBS0nhnkg=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"com_github_jetbrains_kotlin_git": {
"bzlFile": "@@rules_kotlin~//src/main/starlark/core/repositories:compiler.bzl",
"ruleClassName": "kotlin_compiler_git_repository",
"attributes": {
"urls": [
"https://github.com/JetBrains/kotlin/releases/download/v1.9.23/kotlin-compiler-1.9.23.zip"
],
"sha256": "93137d3aab9afa9b27cb06a824c2324195c6b6f6179d8a8653f440f5bd58be88"
}
},
"com_github_jetbrains_kotlin": {
"bzlFile": "@@rules_kotlin~//src/main/starlark/core/repositories:compiler.bzl",
"ruleClassName": "kotlin_capabilities_repository",
"attributes": {
"git_repository_name": "com_github_jetbrains_kotlin_git",
"compiler_version": "1.9.23"
}
},
"com_github_google_ksp": {
"bzlFile": "@@rules_kotlin~//src/main/starlark/core/repositories:ksp.bzl",
"ruleClassName": "ksp_compiler_plugin_repository",
"attributes": {
"urls": [
"https://github.com/google/ksp/releases/download/1.9.23-1.0.20/artifacts.zip"
],
"sha256": "ee0618755913ef7fd6511288a232e8fad24838b9af6ea73972a76e81053c8c2d",
"strip_version": "1.9.23-1.0.20"
}
},
"com_github_pinterest_ktlint": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_file",
"attributes": {
"sha256": "01b2e0ef893383a50dbeb13970fe7fa3be36ca3e83259e01649945b09d736985",
"urls": [
"https://github.com/pinterest/ktlint/releases/download/1.3.0/ktlint"
],
"executable": true
}
},
"rules_android": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"sha256": "cd06d15dd8bb59926e4d65f9003bfc20f9da4b2519985c27e190cddc8b7a7806",
"strip_prefix": "rules_android-0.1.1",
"urls": [
"https://github.com/bazelbuild/rules_android/archive/v0.1.1.zip"
]
}
}
},
"recordedRepoMappingEntries": [
[
"rules_kotlin~",
"bazel_tools",
"bazel_tools"
]
]
}
},
"@@rules_python~//python/uv:uv.bzl%uv": {
"general": {
"bzlTransitiveDigest": "Xpqjnjzy6zZ90Es9Wa888ZLHhn7IsNGbph/e6qoxzw8=",
"usagesDigest": "qI5PVlIum/YAnGJg5oXGHzDkMFWt2aNSUZY4G8PBbic=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"uv": {
"bzlFile": "@@rules_python~//python/uv/private:uv_toolchains_repo.bzl",
"ruleClassName": "uv_toolchains_repo",
"attributes": {
"toolchain_type": "'@@rules_python~//python/uv:uv_toolchain_type'",
"toolchain_names": [
"none"
],
"toolchain_implementations": {
"none": "'@@rules_python~//python:none'"
},
"toolchain_compatible_with": {
"none": [
"@platforms//:incompatible"
]
},
"toolchain_target_settings": {}
}
}
},
"recordedRepoMappingEntries": [
[
"rules_python~",
"platforms",
"platforms"
]
]
}
},
"@@rules_swift~//swift:extensions.bzl%non_module_deps": {
"general": {
"bzlTransitiveDigest": "l+Zu+SMObRQy3DG2LEw0eGVPkYRnyVj+M1QyR5AAFmM=",
"usagesDigest": "l2vIL7SL4tJqHIVLWd78Y/ym+r0II64lmvoX8o+0Bb0=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"com_github_apple_swift_protobuf": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"urls": [
"https://github.com/apple/swift-protobuf/archive/1.20.2.tar.gz"
],
"sha256": "3fb50bd4d293337f202d917b6ada22f9548a0a0aed9d9a4d791e6fbd8a246ebb",
"strip_prefix": "swift-protobuf-1.20.2/",
"build_file": "@@rules_swift~//third_party:com_github_apple_swift_protobuf/BUILD.overlay"
}
},
"com_github_grpc_grpc_swift": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"urls": [
"https://github.com/grpc/grpc-swift/archive/1.16.0.tar.gz"
],
"sha256": "58b60431d0064969f9679411264b82e40a217ae6bd34e17096d92cc4e47556a5",
"strip_prefix": "grpc-swift-1.16.0/",
"build_file": "@@rules_swift~//third_party:com_github_grpc_grpc_swift/BUILD.overlay"
}
},
"com_github_apple_swift_docc_symbolkit": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"urls": [
"https://github.com/apple/swift-docc-symbolkit/archive/refs/tags/swift-5.10-RELEASE.tar.gz"
],
"sha256": "de1d4b6940468ddb53b89df7aa1a81323b9712775b0e33e8254fa0f6f7469a97",
"strip_prefix": "swift-docc-symbolkit-swift-5.10-RELEASE",
"build_file": "@@rules_swift~//third_party:com_github_apple_swift_docc_symbolkit/BUILD.overlay"
}
},
"com_github_apple_swift_nio": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"urls": [
"https://github.com/apple/swift-nio/archive/2.42.0.tar.gz"
],
"sha256": "e3304bc3fb53aea74a3e54bd005ede11f6dc357117d9b1db642d03aea87194a0",
"strip_prefix": "swift-nio-2.42.0/",
"build_file": "@@rules_swift~//third_party:com_github_apple_swift_nio/BUILD.overlay"
}
},
"com_github_apple_swift_nio_http2": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"urls": [
"https://github.com/apple/swift-nio-http2/archive/1.26.0.tar.gz"
],
"sha256": "f0edfc9d6a7be1d587e5b403f2d04264bdfae59aac1d74f7d974a9022c6d2b25",
"strip_prefix": "swift-nio-http2-1.26.0/",
"build_file": "@@rules_swift~//third_party:com_github_apple_swift_nio_http2/BUILD.overlay"
}
},
"com_github_apple_swift_nio_transport_services": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"urls": [
"https://github.com/apple/swift-nio-transport-services/archive/1.15.0.tar.gz"
],
"sha256": "f3498dafa633751a52b9b7f741f7ac30c42bcbeb3b9edca6d447e0da8e693262",
"strip_prefix": "swift-nio-transport-services-1.15.0/",
"build_file": "@@rules_swift~//third_party:com_github_apple_swift_nio_transport_services/BUILD.overlay"
}
},
"com_github_apple_swift_nio_extras": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"urls": [
"https://github.com/apple/swift-nio-extras/archive/1.4.0.tar.gz"
],
"sha256": "4684b52951d9d9937bb3e8ccd6b5daedd777021ef2519ea2f18c4c922843b52b",
"strip_prefix": "swift-nio-extras-1.4.0/",
"build_file": "@@rules_swift~//third_party:com_github_apple_swift_nio_extras/BUILD.overlay"
}
},
"com_github_apple_swift_log": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"urls": [
"https://github.com/apple/swift-log/archive/1.4.4.tar.gz"
],
"sha256": "48fe66426c784c0c20031f15dc17faf9f4c9037c192bfac2f643f65cb2321ba0",
"strip_prefix": "swift-log-1.4.4/",
"build_file": "@@rules_swift~//third_party:com_github_apple_swift_log/BUILD.overlay"
}
},
"com_github_apple_swift_nio_ssl": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"urls": [
"https://github.com/apple/swift-nio-ssl/archive/2.23.0.tar.gz"
],
"sha256": "4787c63f61dd04d99e498adc3d1a628193387e41efddf8de19b8db04544d016d",
"strip_prefix": "swift-nio-ssl-2.23.0/",
"build_file": "@@rules_swift~//third_party:com_github_apple_swift_nio_ssl/BUILD.overlay"
}
},
"com_github_apple_swift_collections": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"urls": [
"https://github.com/apple/swift-collections/archive/1.0.4.tar.gz"
],
"sha256": "d9e4c8a91c60fb9c92a04caccbb10ded42f4cb47b26a212bc6b39cc390a4b096",
"strip_prefix": "swift-collections-1.0.4/",
"build_file": "@@rules_swift~//third_party:com_github_apple_swift_collections/BUILD.overlay"
}
},
"com_github_apple_swift_atomics": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"urls": [
"https://github.com/apple/swift-atomics/archive/1.1.0.tar.gz"
],
"sha256": "1bee7f469f7e8dc49f11cfa4da07182fbc79eab000ec2c17bfdce468c5d276fb",
"strip_prefix": "swift-atomics-1.1.0/",
"build_file": "@@rules_swift~//third_party:com_github_apple_swift_atomics/BUILD.overlay"
}
},
"build_bazel_rules_swift_index_import": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"build_file": "@@rules_swift~//third_party:build_bazel_rules_swift_index_import/BUILD.overlay",
"canonical_id": "index-import-5.8",
"urls": [
"https://github.com/MobileNativeFoundation/index-import/releases/download/5.8.0.1/index-import.tar.gz"
],
"sha256": "28c1ffa39d99e74ed70623899b207b41f79214c498c603915aef55972a851a15"
}
},
"build_bazel_rules_swift_local_config": {
"bzlFile": "@@rules_swift~//swift/internal:swift_autoconfiguration.bzl",
"ruleClassName": "swift_autoconfiguration",
"attributes": {}
}
},
"recordedRepoMappingEntries": [
[
"rules_swift~",
"bazel_tools",
"bazel_tools"
]
]
}
}
}
}
-3
View File
@@ -1,3 +0,0 @@
# This file marks sparkle_workspace as a separate Bazel workspace.
# It prevents Bazel from looking up to the parent directory's workspace.
# The actual dependencies are defined in MODULE.bazel (bzlmod).
@@ -1,3 +1,4 @@
load("@build_bazel_rules_apple//apple:macos.bzl", "macos_command_line_application")
load("//tools:copts.bzl", "COPTS")
cc_library(
@@ -81,3 +82,11 @@ cc_binary(
":fixed_action_point_distances_timer",
],
)
macos_command_line_application(
name = "fixed_action_point_distances_timer_macos_app",
minimum_os_version = "10.15",
deps = [
":fixed_action_point_distances_timer",
],
)
@@ -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
@@ -24,11 +24,11 @@ MonoBehaviour:
m_InternalBundleIdMode: 1
m_AssetLoadMode: 0
m_BundledAssetProviderType:
m_AssemblyName: Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
m_ClassName: UnityEngine.ResourceManagement.ResourceProviders.BundledAssetProvider
m_AssemblyName:
m_ClassName:
m_AssetBundleProviderType:
m_AssemblyName: Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
m_ClassName: UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider
m_AssemblyName:
m_ClassName:
m_IgnoreUnsupportedFilesInBuild: 0
m_UniqueBundleIds: 0
m_EnableJsonCatalog: 0
@@ -53,7 +53,7 @@ MonoBehaviour:
m_RemoteCatalogBuildPath:
m_Id: 2af7f42f684fd4296a199c9e60624f6f
m_RemoteCatalogLoadPath:
m_Id: 55b13501972b540abb7332a5bad98342
m_Id: 55b13501972b540abb7332a5bad98342
m_ContentStateBuildPathProfileVariableName:
m_CustomContentStateBuildPath:
m_ContentStateBuildPath:
@@ -77,7 +77,7 @@ MonoBehaviour:
- m_Id: 32a6cb134d6b249acba4c1423e9667a4
m_Value: '[UnityEngine.AddressableAssets.Addressables.BuildPath]/[BuildTarget]'
- m_Id: 55b13501972b540abb7332a5bad98342
m_Value: 'https://assets.eagle0.net/addressables/[BuildTarget]'
m_Value: https://assets.eagle0.net/addressables/[BuildTarget]
- m_Id: b347880d4285c4a25a539211b563a545
m_Value: '[UnityEditor.EditorUserBuildSettings.activeBuildTarget]'
m_ProfileEntryNames:
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 568267bd7b6fa4581adaa40faa984594
guid: cf11bfc3cc63e42a4a194dc4571dee36
folderAsset: yes
DefaultImporter:
externalObjects: {}
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: a1b5efbbd8a2c4df59c9f7d542172b18
guid: e038e6458a02043e2af664dd226e5a32
DefaultImporter:
externalObjects: {}
userData:
@@ -67,18 +67,6 @@ namespace Auth {
return (response.AuthUrl, response.State);
}
/// <summary>
/// Check OAuth status once (non-polling). Used when processing deep links
/// where we know OAuth has already completed.
/// Routed to Go auth service.
/// </summary>
/// <param name="state">State token from OAuth flow</param>
/// <returns>Response with status, tokens and user info</returns>
public async Task<CheckOAuthStatusResponse> CheckOAuthStatusAsync(string state) {
var request = new CheckOAuthStatusRequest { State = state };
return await _authServiceClient.CheckOAuthStatusAsync(request);
}
/// <summary>
/// Poll for OAuth completion. Blocks until success, failure, or timeout.
/// The server handles the OAuth callback and token exchange.
@@ -199,23 +187,6 @@ namespace Auth {
}
}
/// <summary>
/// Exchange a session transfer code for tokens.
/// Used when user clicks the deep link from the invitation download page.
/// Routed to Go auth service.
/// </summary>
public async Task<ExchangeSessionTransferCodeResponse> ExchangeSessionTransferCodeAsync(
string code) {
Debug.Log("[AuthClient] Exchanging session transfer code via gRPC...");
var request = new ExchangeSessionTransferCodeRequest { Code = code };
var response = await _authServiceClient.ExchangeSessionTransferCodeAsync(request);
Debug.Log(
$"[AuthClient] Session transfer successful: userId={response.User?.UserId}, displayName={response.User?.DisplayName}");
return response;
}
public void Dispose() {
_authServiceChannel?.Dispose();
_eagleChannel?.Dispose();
@@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using common;
using Grpc.Core;
using Net.Eagle0.Eagle.Api.Auth;
using UnityEngine;
@@ -21,10 +20,6 @@ namespace Auth {
private string _currentAuthServiceUrl;
private string _currentEagleUrl;
private OAuthProvider _currentLoginProvider; // Track provider during login
private string _pendingSessionTransferCode; // Queued if deep link arrives before auth
// client ready
private string _pendingOAuthState; // Queued if OAuth result arrives before auth
// client ready
// Events for UI updates
public event Action<UserInfo> OnLoginSuccess;
@@ -50,175 +45,6 @@ namespace Auth {
TokenStorage.InitializeCache();
// AuthClient is created lazily when SetServerUrls is called
// Register deep link handler for OAuth callback
Application.deepLinkActivated += OnDeepLinkActivated;
// On Windows, listen for deep links from other instances via named pipe
SingleInstanceEnforcer.OnDeepLinkReceived += OnDeepLinkActivated;
// Check if app was launched via deep link
if (!string.IsNullOrEmpty(Application.absoluteURL)) {
OnDeepLinkActivated(Application.absoluteURL);
}
// On Windows, URL scheme launches pass the URL as a command line argument
CheckCommandLineForDeepLink();
}
private void CheckCommandLineForDeepLink() {
var args = System.Environment.GetCommandLineArgs();
// Args[0] is the executable path, check remaining args for eagle0:// URLs
for (int i = 1; i < args.Length; i++) {
if (args[i].StartsWith("eagle0://")) {
Debug.Log($"[OAuthManager] Found deep link in command line: {args[i]}");
OnDeepLinkActivated(args[i]);
break;
}
}
}
private void OnDeepLinkActivated(string url) {
Debug.Log($"[OAuthManager] Deep link received: {url}");
// Handle session transfer deep link: eagle0://auth/session/{code}
// This is for existing users - immediate login with session transfer code
const string sessionTransferPrefix = "eagle0://auth/session/";
if (url.StartsWith(sessionTransferPrefix)) {
var code = url.Substring(sessionTransferPrefix.Length).TrimEnd('/');
Debug.Log(
$"[OAuthManager] Session transfer deep link received, code length: {code.Length}");
WindowFocusManager.BringToForeground();
_ = HandleSessionTransferAsync(code);
return;
}
// Handle OAuth result deep link: eagle0://auth/result/{state}
// This is for new users - poll CheckOAuthStatus to complete registration
const string resultPrefix = "eagle0://auth/result/";
if (url.StartsWith(resultPrefix)) {
var state = url.Substring(resultPrefix.Length).TrimEnd('/');
Debug.Log($"[OAuthManager] OAuth result deep link received, state: {state}");
WindowFocusManager.BringToForeground();
_ = HandleOAuthResultAsync(state);
return;
}
// Handle eagle0://auth/complete - brings app to foreground after OAuth
if (url.StartsWith("eagle0://auth")) {
Debug.Log("[OAuthManager] OAuth callback deep link - bringing app to foreground");
WindowFocusManager.BringToForeground();
}
}
/// <summary>
/// Handle session transfer code from invitation deep link.
/// Exchanges the one-time code for tokens and logs the user in.
/// </summary>
private async Task HandleSessionTransferAsync(string code) {
if (_authClient == null) {
// Auth client not ready yet - queue the code for later processing
Debug.Log(
"[OAuthManager] Auth client not ready, queuing session transfer code for later");
_pendingSessionTransferCode = code;
return;
}
try {
Debug.Log("[OAuthManager] Exchanging session transfer code for tokens...");
var response = await _authClient.ExchangeSessionTransferCodeAsync(code);
// Store tokens with actual provider from response
var providerName = response.User.Provider.ToString().ToLowerInvariant();
TokenStorage.StoreTokens(
response.AccessToken,
response.RefreshToken,
response.ExpiresAt,
response.User.UserId,
response.User.DisplayName,
providerName);
Debug.Log(
$"[OAuthManager] Session transfer successful for {response.User.DisplayName}");
OnLoginSuccess?.Invoke(response.User);
} catch (Exception ex) {
Debug.LogWarning($"[OAuthManager] Session transfer failed: {ex.Message}");
OnLoginFailed?.Invoke($"Sign-in link expired or invalid. Please sign in manually.");
}
}
/// <summary>
/// Handle OAuth result deep link for new users.
/// Polls CheckOAuthStatus to complete registration flow (invitation validation, display
/// name).
/// </summary>
private async Task HandleOAuthResultAsync(string state) {
if (_authClient == null) {
// Auth client not ready yet - queue the state for later processing
Debug.Log("[OAuthManager] Auth client not ready, queuing OAuth state for later");
_pendingOAuthState = state;
return;
}
try {
Debug.Log($"[OAuthManager] Checking OAuth status for state: {state}");
var response = await _authClient.CheckOAuthStatusAsync(state);
switch (response.Status) {
case OAuthStatus.Success:
// Store tokens
var providerName = response.User.Provider.ToString().ToLowerInvariant();
TokenStorage.StoreTokens(
response.AccessToken,
response.RefreshToken,
response.ExpiresAt,
response.User.UserId,
response.User.DisplayName,
providerName);
// Check if new user needs display name
if (response.IsNewUser || string.IsNullOrEmpty(response.User.DisplayName)) {
Debug.Log("[OAuthManager] New user needs to set display name");
OnNewUserNeedsDisplayName?.Invoke(true);
} else {
Debug.Log(
$"[OAuthManager] OAuth successful for {response.User.DisplayName}");
OnLoginSuccess?.Invoke(response.User);
}
break;
case OAuthStatus.InvitationRequired:
Debug.Log("[OAuthManager] Invitation code required for new account");
OnLoginFailed?.Invoke(
"An invitation code is required to create a new account.");
break;
case OAuthStatus.Failed:
Debug.LogWarning($"[OAuthManager] OAuth failed: {response.ErrorMessage}");
OnLoginFailed?.Invoke(response.ErrorMessage);
break;
case OAuthStatus.Expired:
Debug.LogWarning("[OAuthManager] OAuth session expired");
OnLoginFailed?.Invoke("Sign-in session expired. Please try again.");
break;
case OAuthStatus.Pending:
// This shouldn't happen since OAuth already completed, but handle it
Debug.LogWarning("[OAuthManager] OAuth still pending - unexpected");
OnLoginFailed?.Invoke("Sign-in incomplete. Please try again.");
break;
default:
Debug.LogWarning($"[OAuthManager] Unknown OAuth status: {response.Status}");
OnLoginFailed?.Invoke("Sign-in failed. Please try again.");
break;
}
} catch (Exception ex) {
Debug.LogWarning($"[OAuthManager] OAuth status check failed: {ex.Message}");
OnLoginFailed?.Invoke($"Sign-in failed: {ex.Message}");
}
}
/// <summary>
@@ -238,27 +64,9 @@ namespace Auth {
_currentEagleUrl = eagleUrl;
_authClient = new AuthClient(authServiceUrl, eagleUrl);
Debug.Log($"[OAuthManager] Configured: authService={authServiceUrl}, eagle={eagleUrl}");
// Process any pending session transfer that arrived before auth client was ready
if (!string.IsNullOrEmpty(_pendingSessionTransferCode)) {
var code = _pendingSessionTransferCode;
_pendingSessionTransferCode = null;
Debug.Log("[OAuthManager] Processing queued session transfer code");
_ = HandleSessionTransferAsync(code);
}
// Process any pending OAuth state that arrived before auth client was ready
if (!string.IsNullOrEmpty(_pendingOAuthState)) {
var state = _pendingOAuthState;
_pendingOAuthState = null;
Debug.Log("[OAuthManager] Processing queued OAuth state");
_ = HandleOAuthResultAsync(state);
}
}
private void OnDestroy() {
Application.deepLinkActivated -= OnDeepLinkActivated;
SingleInstanceEnforcer.OnDeepLinkReceived -= OnDeepLinkActivated;
_authClient?.Dispose();
if (Instance == this) { Instance = null; }
}
@@ -272,9 +80,7 @@ namespace Auth {
/// <summary>
/// Start OAuth login with specified provider.
/// Opens system browser for user consent, then quits the app.
/// The browser's "Return to Eagle" deep link will relaunch the app
/// with the auth session, which HandleSessionTransferAsync will process.
/// Opens system browser for user consent, then polls for completion.
/// </summary>
public async Task<CheckOAuthStatusResponse> LoginAsync(OAuthProvider provider) {
EnsureConfigured();
@@ -284,23 +90,41 @@ namespace Auth {
// Get OAuth URL and state from server
var (authUrl, state) = await _authClient.GetOAuthUrlAsync(provider);
// Minimize game window so user can see the browser
WindowFocusManager.MinimizeForExternalBrowser();
// Open system browser
Debug.Log($"[OAuthManager] Opening browser for OAuth: {authUrl}");
Debug.Log($"[OAuthManager] Opening browser: {authUrl}");
Application.OpenURL(authUrl);
// Quit the app - the browser's "Return to Eagle" deep link will
// relaunch a fresh instance with the auth session code.
// This avoids the dual-instance problem where both the original
// app (waiting/polling) and the deep-linked app are running.
Debug.Log("[OAuthManager] Quitting app - will relaunch via deep link after OAuth");
Application.Quit();
// Poll for OAuth completion (server handles the callback)
var response = await _authClient.PollForOAuthCompletionAsync(state);
// This code won't execute since we're quitting, but return null
// to satisfy the compiler. The new instance will handle auth
// via OnDeepLinkActivated -> HandleSessionTransferAsync.
return null;
// Bring game back to foreground now that auth is complete
WindowFocusManager.BringToForeground();
// Store tokens with provider info
var providerName = provider.ToString().ToLowerInvariant();
TokenStorage.StoreTokens(
response.AccessToken,
response.RefreshToken,
response.ExpiresAt,
response.User.UserId,
response.User.DisplayName ?? "",
providerName);
if (response.IsNewUser) {
OnNewUserNeedsDisplayName?.Invoke(true);
} else {
OnLoginSuccess?.Invoke(response.User);
}
return response;
} catch (Exception ex) {
Debug.LogWarning($"[OAuthManager] Login failed: {ex.Message}");
// Bring game back to foreground even on failure
WindowFocusManager.BringToForeground();
Debug.LogError($"[OAuthManager] Login failed: {ex.Message}");
OnLoginFailed?.Invoke(ex.Message);
throw;
}
@@ -360,13 +184,6 @@ namespace Auth {
Debug.Log($"[OAuthManager] Session restored for {response.User.DisplayName}");
OnLoginSuccess?.Invoke(response.User);
return true;
} catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound) {
// User no longer exists on server - remove the invalid account
var accountKey = TokenStorage.CurrentAccount?.AccountKey;
Debug.LogWarning(
$"[OAuthManager] User not found on server, removing invalid account: {accountKey}");
if (accountKey != null) { TokenStorage.RemoveAccount(accountKey); }
return false;
} catch (Exception ex) {
Debug.LogWarning($"[OAuthManager] Session validation failed: {ex.Message}");
TokenStorage.Clear();
@@ -457,13 +274,6 @@ namespace Auth {
$"[OAuthManager] Connected with stored account: {response.User.DisplayName}");
OnLoginSuccess?.Invoke(response.User);
return true;
} catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound) {
// User no longer exists on server - remove the invalid account
Debug.LogWarning(
$"[OAuthManager] User not found on server, removing account: {account.AccountKey}");
TokenStorage.RemoveAccount(account.AccountKey);
OnLoginFailed?.Invoke("Account no longer exists. Please sign in again.");
return false;
} catch (Exception ex) {
Debug.LogWarning($"[OAuthManager] Session validation failed: {ex.Message}");
OnLoginFailed?.Invoke("Session invalid. Please sign in again.");
@@ -1,9 +0,0 @@
/// <summary>
/// Build information generated at build time.
/// This file is auto-generated by BuildInfoGenerator - do not edit manually.
/// </summary>
public static class BuildInfo {
public const string GitCommitHash = "development";
public const string GitCommitShort = "dev";
public const string BuildTimestamp = "";
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 9f6bcf450bd4543d39dbba8e09b7605c
@@ -94,8 +94,8 @@ MonoBehaviour:
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 18
m_fontSizeBase: 18
m_fontSize: 30
m_fontSizeBase: 30
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
@@ -152,11 +152,11 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 240
m_MinWidth: 450
m_MinHeight: 60
m_PreferredWidth: 240
m_PreferredWidth: 450
m_PreferredHeight: 60
m_FlexibleWidth: 0
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &3769343953609136136
@@ -253,8 +253,8 @@ MonoBehaviour:
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 18
m_fontSizeBase: 18
m_fontSize: 36
m_fontSizeBase: 36
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
@@ -311,9 +311,9 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 200
m_MinWidth: 180
m_MinHeight: -1
m_PreferredWidth: 200
m_PreferredWidth: 180
m_PreferredHeight: -1
m_FlexibleWidth: -1
m_FlexibleHeight: -1
@@ -540,7 +540,7 @@ MonoBehaviour:
m_MinHeight: -1
m_PreferredWidth: 200
m_PreferredHeight: -1
m_FlexibleWidth: 0
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &4995675505853930853
@@ -636,8 +636,8 @@ MonoBehaviour:
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 18
m_fontSizeBase: 18
m_fontSize: 36
m_fontSizeBase: 36
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
@@ -1285,9 +1285,9 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 300
m_MinWidth: 700
m_MinHeight: -1
m_PreferredWidth: 300
m_PreferredWidth: 700
m_PreferredHeight: -1
m_FlexibleWidth: 1
m_FlexibleHeight: -1
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using eagle;
using Eagle0.Tutorial;
using Net.Eagle0.Eagle.Api;
@@ -74,11 +73,6 @@ public class AvailableGameItem : MonoBehaviour {
});
}
// Pass pre-resolved names from AvailableLeader.Name so names display
// immediately without needing to look them up via ClientTextProvider
heroDropdownController.PreResolvedNames =
leaders.Where(l => !string.IsNullOrEmpty(l.Name))
.ToDictionary(l => l.NameTextId, l => l.Name);
heroDropdownController.FallbackText = "Random";
heroDropdownController.AvailableHeroes = heroOptions;
}
@@ -10,7 +10,6 @@ using System.Threading.Tasks;
using Auth;
using common;
using common.GUIUtils;
using common.WhatsNew;
using eagle;
using Eagle0.Tutorial;
using Grpc.Core;
@@ -137,7 +136,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public Canvas connectionCanvas;
public Canvas eagleCanvas;
public GameObject shardokContainer;
public Canvas shardokCanvas;
public CustomBattleHandler customBattleHandler;
List<GameInfo> fetchedRunningGames;
@@ -151,10 +150,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
private readonly Object pendingReplyLock = new Object();
private int _connectedEnvironmentIndex = -1; // -1 means not connected
private List<StoredAccountButton> _storedAccountButtons = new();
private bool _exitedFullscreenForNewUser = false; // Track if we exited fullscreen for login
private FullScreenMode _previousFullScreenMode; // Store mode to restore after login
private int _previousWidth; // Store resolution to restore after login
private int _previousHeight;
public ClientPregeneratedText clientPregeneratedText;
const String BaseDomain = "eagle0.net";
const String EnvironmentKey = "environmentKey";
@@ -195,6 +192,10 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
_handleLobbyResponse(lobbyResponse);
}
public void ReceivePregeneratedTextUpdate(PregeneratedTextResponse response) {
clientPregeneratedText.Load(response.PregeneratedText);
}
void Start() {
var currentResolution = Screen.currentResolution;
var resolutions = Screen.resolutions;
@@ -205,7 +206,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
connectionCanvas.gameObject.SetActive(true);
eagleCanvas.gameObject.SetActive(false);
shardokContainer.SetActive(false);
shardokCanvas.gameObject.SetActive(false);
connectionCanvas.enabled = true;
connectionPanel.gameObject.SetActive(true);
@@ -227,10 +228,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
// Initialize status text
UpdateConnectionStatus();
// Configure bug report webhook
BugReportPanelController.SetWebhookUrl(
"https://discord.com/api/webhooks/1468260773187944448/PdjrzE3CXc1m58VbubUugvJwXDSKdkgaRI4GjgHcxMDW5D4L-JkDaeziLRnCgr972H6J");
// Try to restore existing OAuth session
TryRestoreSession();
}
@@ -238,13 +235,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
private void SetupConnectionEnvironmentDropdown() {
if (connectionEnvironmentDropdown == null) return;
// Only show environment dropdown in Unity Editor; always use prod in builds
if (!Application.isEditor) {
connectionEnvironmentDropdown.gameObject.SetActive(false);
PlayerPrefs.SetInt(EnvironmentKey, 0); // Force prod
return;
}
connectionEnvironmentDropdown.ClearOptions();
connectionEnvironmentDropdown.AddOptions(EnvironmentDisplayNames);
connectionEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
@@ -311,24 +301,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
var accounts = OAuthManager.Instance?.GetStoredAccounts();
if (accounts == null || accounts.Count == 0) {
TutorialManager.Instance?.OnGameEvent("auth_panel_shown");
// Show hint for users coming from invitation flow
if (oauthStatusText != null) {
oauthStatusText.text =
"Came from an invitation? Check your browser for a 'Complete Sign-In' button.";
}
// Exit fullscreen so user can see browser with the sign-in link
if (Screen.fullScreen) {
_exitedFullscreenForNewUser = true;
_previousFullScreenMode = Screen.fullScreenMode;
_previousWidth = Screen.width;
_previousHeight = Screen.height;
Screen.fullScreen = false;
Debug.Log(
$"[ConnectionHandler] Exited fullscreen for new user sign-in flow (was {_previousWidth}x{_previousHeight})");
}
} else {
// Clear any previous hint
if (oauthStatusText != null) { oauthStatusText.text = ""; }
}
}
@@ -416,16 +388,12 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
cancelCustomBattleButton.onClick.AddListener(CancelCustomBattle);
}
// Set up environment dropdown (only in Unity Editor)
// Set up environment dropdown
if (lobbyEnvironmentDropdown != null) {
if (!Application.isEditor) {
lobbyEnvironmentDropdown.gameObject.SetActive(false);
} else {
lobbyEnvironmentDropdown.ClearOptions();
lobbyEnvironmentDropdown.AddOptions(EnvironmentDisplayNames);
lobbyEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
lobbyEnvironmentDropdown.onValueChanged.AddListener(OnLobbyEnvironmentChanged);
}
lobbyEnvironmentDropdown.ClearOptions();
lobbyEnvironmentDropdown.AddOptions(EnvironmentDisplayNames);
lobbyEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
lobbyEnvironmentDropdown.onValueChanged.AddListener(OnLobbyEnvironmentChanged);
}
}
@@ -510,11 +478,13 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
if (oauthStatusText != null) { oauthStatusText.text = "Checking for existing session..."; }
// TryRestoreSessionAsync() fires OnLoginSuccess if successful, which triggers
// ConnectWithOAuth() via the OnOAuthLoginSuccess handler. We only need to clear
// the status text if restore failed (user not logged in).
var restored = await OAuthManager.Instance.TryRestoreSessionAsync();
if (!restored && oauthStatusText != null) { oauthStatusText.text = ""; }
if (restored) {
// Session restored, connect to lobby
ConnectWithOAuth();
} else if (oauthStatusText != null) {
oauthStatusText.text = "";
}
}
private async void OnOAuthLoginClicked(OAuthProvider provider) {
@@ -529,7 +499,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
await OAuthManager.Instance.LoginAsync(provider);
// OnLoginSuccess or OnNewUserNeedsDisplayName will be called
} catch (Exception ex) {
Debug.LogWarning($"OAuth login failed: {ex.Message}");
Debug.LogError($"OAuth login failed: {ex.Message}");
if (oauthStatusText != null) { oauthStatusText.text = $"Login failed: {ex.Message}"; }
}
}
@@ -537,17 +507,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
private void OnOAuthLoginSuccess(UserInfo user) {
MainQueue.Q.Enqueue(() => {
if (oauthStatusText != null) { oauthStatusText.text = $"Welcome, {user.DisplayName}!"; }
// Restore fullscreen if we exited it for the new user sign-in flow
if (_exitedFullscreenForNewUser) {
_exitedFullscreenForNewUser = false;
Screen.SetResolution(_previousWidth, _previousHeight, _previousFullScreenMode);
Debug.Log(
$"[ConnectionHandler] Restored fullscreen: {_previousWidth}x{_previousHeight} {_previousFullScreenMode}");
}
// Show what's new for returning users
WhatsNewManager.Instance?.CheckAndShow();
ConnectWithOAuth();
});
}
@@ -644,7 +603,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public void CancelCustomBattle() {
customBattlePanel.SetActive(false);
shardokContainer.SetActive(false);
shardokCanvas.gameObject.SetActive(false);
connectionCanvas.gameObject.SetActive(true);
gameSelectionPanel.SetActive(true);
StartListeningForLobbyUpdates();
@@ -806,7 +765,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
/// <summary>
/// Auto-create and launch a 7-faction game for first-time users.
/// Auto-create and launch a 1-player game for first-time users.
/// </summary>
private void AutoCreateFirstGame() {
if (fetchedNewGameLeaders == null || fetchedNewGameLeaders.Count == 0) {
@@ -818,9 +777,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
var randomIndex = new System.Random().Next(fetchedNewGameLeaders.Count);
var leaderTextId = fetchedNewGameLeaders[randomIndex].NameTextId;
// Create a 7-faction game (1 human + 6 AI) - same as lobby default
Debug.Log($"[ConnectionHandler] Auto-creating 7-faction game with leader {leaderTextId}");
CreateGame(leaderTextId, 1, 7);
// Create a 1-player, 1-human game (solo vs AI)
Debug.Log($"[ConnectionHandler] Auto-creating 1-player game with leader {leaderTextId}");
CreateGame(leaderTextId, 1, 1);
}
private void StartListeningForLobbyUpdates() {
@@ -861,19 +820,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
eagleConnection.EagleGrpcClient,
eagleConnection.credentials,
EagleConnection.EagleCancellationToken);
_persistentClientConnection.OnChannelDead = () => {
// Queue to main thread to avoid threading issues
MainQueue.Q.Enqueue(() => {
Debug.Log("[ConnectionHandler] Channel dead, recreating connection");
_createConnection();
});
};
_persistentClientConnection.OnCommandError = (errorMessage) => {
// Queue to main thread to display error via ErrorHandler
MainQueue.Q.Enqueue(() => {
Debug.LogError($"[ConnectionHandler] Command rejected by server: {errorMessage}");
});
};
_persistentClientConnection.Connect();
errorHandler.PersistentClientConnection = _persistentClientConnection;
@@ -914,13 +860,13 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
_createConnection();
// If connection creation failed (e.g., no valid token), don't proceed
if (_persistentClientConnection == null) {
Debug.LogError("[ConnectionHandler] Connection creation failed, cannot proceed");
return;
}
try {
_persistentClientConnection.SendUpdateStreamRequestAsync(new UpdateStreamRequest {
PregeneratedTextRequest =
new PregeneratedTextRequest {
KnownPregeneratedTextHash = clientPregeneratedText.Hash
}
});
RequestMaps();
StartListeningForLobbyUpdates();
} catch (RpcException e) {
@@ -72,11 +72,6 @@ public class CreateGameItem : MonoBehaviour {
});
}
// Pass pre-resolved names from AvailableLeader.Name so names display
// immediately without needing to look them up via ClientTextProvider
heroDropdownController.PreResolvedNames =
leaders.Where(l => !string.IsNullOrEmpty(l.Name))
.ToDictionary(l => l.NameTextId, l => l.Name);
heroDropdownController.FallbackText = "Random";
heroDropdownController.AvailableHeroes = heroOptions;
heroDropdownController.HeadshotImage.gameObject.SetActive(false);
@@ -22,7 +22,7 @@ using VictoryCondition = Net.Eagle0.Common.VictoryCondition;
public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
public GameObject yourArmiesArea;
public GameObject aiArmiesArea;
public GameObject shardokContainer;
public Canvas shardokCanvas;
public Toggle defenderToggle;
public TMP_Dropdown mapDropdown;
@@ -103,8 +103,8 @@ public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
heroImages: _heroImages,
battalionNames: new Dictionary<int, string> { { 0, "no name" } });
shardokContainer.SetActive(true);
shardokContainer.GetComponent<ShardokGameController>().SetUpGame(_shardokModel);
shardokCanvas.gameObject.SetActive(true);
shardokCanvas.GetComponent<ShardokGameController>().SetUpGame(_shardokModel);
GameId = newGameResponse.EagleGameId;
Register();
@@ -116,6 +116,7 @@ public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
_youAreDefender = defenderToggle.isOn;
var yourSetupInfo = new PlayerSetupInfo {
UserName = _eagleConnection.playerName,
Defender = _youAreDefender,
EagleFactionId = 1,
Food = 2000,
@@ -137,6 +138,7 @@ public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
}
var aiSetupInfo = new PlayerSetupInfo {
UserName = "AI",
Defender = !_youAreDefender,
EagleFactionId = 2,
Food = 2000,
@@ -436,9 +438,6 @@ public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
public GameId GameId { get; private set; }
public GameId? CurrentEagleToken => null;
// CustomBattleHandler doesn't use Eagle commands, so this is unused
public Func<long, bool> HasPendingCommandWithToken { get; set; }
public int LastUnfilteredResultCount => 0;
public List<IClientConnectionSubscriber.ShardokViewStatus> ShardokViewStatuses =>
new() { new() {
@@ -30,14 +30,8 @@ public class RunningGameItem : MonoBehaviour {
this.gameId = gameId;
gameIdField.text = string.Format("{0:X}", gameId);
// Use pre-resolved name from server if available, otherwise fall back to text lookup
string leaderName;
if (!string.IsNullOrEmpty(leader.Name)) {
leaderName = leader.Name;
} else {
var textEntry = ClientTextProvider.Provider.GetTextEntry(leader.NameTextId);
leaderName = textEntry != null ? textEntry.Text : "Hero";
}
var textEntry = ClientTextProvider.Provider.GetTextEntry(leader.NameTextId);
var leaderName = textEntry != null ? textEntry.Text : "Hero";
leaderField.text = string.Format(
"{0} ({1})",
leaderName,
@@ -29,9 +29,9 @@ RectTransform:
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3084837499067662000}
m_RootOrder: 1
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -101,27 +101,24 @@ MonoBehaviour:
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 1
m_HorizontalAlignment: 2
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_enableWordWrapping: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -152,9 +149,9 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 300
m_MinWidth: 700
m_MinHeight: -1
m_PreferredWidth: -1
m_PreferredWidth: 700
m_PreferredHeight: -1
m_FlexibleWidth: 1
m_FlexibleHeight: -1
@@ -188,9 +185,9 @@ RectTransform:
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3084837499067662000}
m_RootOrder: 2
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -264,23 +261,20 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_enableWordWrapping: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -311,11 +305,11 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 300
m_MinWidth: 180
m_MinHeight: -1
m_PreferredWidth: 300
m_PreferredWidth: 180
m_PreferredHeight: -1
m_FlexibleWidth: 0
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &1722990544386167487
@@ -328,6 +322,8 @@ GameObject:
m_Component:
- component: {fileID: 5405233146667072049}
- component: {fileID: 9089165643261525487}
- component: {fileID: 3309908850111668078}
- component: {fileID: 7352302768073002882}
- component: {fileID: 28101565677326836}
- component: {fileID: 4394564974352927053}
- component: {fileID: 1180739972031081654}
@@ -348,10 +344,10 @@ RectTransform:
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 933970514342940104}
m_Father: {fileID: 3084837499067662000}
m_RootOrder: 3
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -366,6 +362,74 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1722990544386167487}
m_CullTransparentMesh: 0
--- !u!114 &3309908850111668078
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1722990544386167487}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 1a12ddbc47b17cd478cb447d1113a22b, type: 3}
m_Name:
m_EditorClassIdentifier:
buttonText: waiting...
buttonEvent:
m_PersistentCalls:
m_Calls: []
hoverSound: {fileID: 0}
clickSound: {fileID: 0}
normalText: {fileID: 1528463102076488116}
soundSource: {fileID: 0}
useCustomContent: 0
enableButtonSounds: 0
useHoverSound: 1
useClickSound: 1
--- !u!114 &7352302768073002882
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1722990544386167487}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a9d2f9067277ade4e9b47012ea4d9e17, type: 3}
m_Name:
m_EditorClassIdentifier:
_effectGradient:
serializedVersion: 2
key0: {r: 0, g: 0.5568628, b: 1, a: 1}
key1: {r: 0.23529412, g: 0.85457677, b: 0.972549, a: 1}
key2: {r: 0, g: 0, b: 0, a: 0}
key3: {r: 0, g: 0, b: 0, a: 0}
key4: {r: 0, g: 0, b: 0, a: 0}
key5: {r: 0, g: 0, b: 0, a: 0}
key6: {r: 0, g: 0, b: 0, a: 0}
key7: {r: 0, g: 0, b: 0, a: 0}
ctime0: 0
ctime1: 65535
ctime2: 0
ctime3: 0
ctime4: 0
ctime5: 0
ctime6: 0
ctime7: 0
atime0: 0
atime1: 65535
atime2: 0
atime3: 0
atime4: 0
atime5: 0
atime6: 0
atime7: 0
m_Mode: 0
m_NumColorKeys: 2
m_NumAlphaKeys: 2
_gradientType: 0
_blendMode: 2
_offset: 0
--- !u!114 &28101565677326836
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -410,7 +474,7 @@ MonoBehaviour:
m_OnClick:
m_PersistentCalls:
m_Calls:
- m_Target: {fileID: 7494010136792472269}
- m_Target: {fileID: 0}
m_TargetAssemblyTypeName:
m_MethodName: JoinClicked
m_Mode: 1
@@ -465,11 +529,11 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 120
m_MinWidth: 200
m_MinHeight: -1
m_PreferredWidth: 120
m_PreferredWidth: 200
m_PreferredHeight: -1
m_FlexibleWidth: 0
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &3410015936189372563
@@ -501,9 +565,9 @@ RectTransform:
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3084837499067662000}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -566,8 +630,8 @@ MonoBehaviour:
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 18
m_fontSizeBase: 18
m_fontSize: 30
m_fontSizeBase: 30
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
@@ -577,23 +641,20 @@ MonoBehaviour:
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_enableWordWrapping: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -624,11 +685,11 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 240
m_MinWidth: 450
m_MinHeight: 60
m_PreferredWidth: 240
m_PreferredWidth: 450
m_PreferredHeight: 60
m_FlexibleWidth: 0
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &7494010136792472269
@@ -661,13 +722,13 @@ RectTransform:
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 4435729753412350698}
- {fileID: 590678445898122332}
- {fileID: 9118077588552391645}
- {fileID: 5405233146667072049}
m_Father: {fileID: 0}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
@@ -782,9 +843,9 @@ RectTransform:
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 5405233146667072049}
m_RootOrder: 0
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
@@ -847,34 +908,31 @@ MonoBehaviour:
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 18
m_fontSizeBase: 18
m_fontSize: 24
m_fontSizeBase: 24
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 1
m_HorizontalAlignment: 2
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_enableWordWrapping: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
@@ -16,14 +16,8 @@ public class WaitingGameItem : MonoBehaviour {
gameIdField.text = string.Format("{0:X}", gameId);
playerCountField.text = playersString;
// Use pre-resolved name from server if available, otherwise fall back to text lookup
string leaderName;
if (!string.IsNullOrEmpty(leader.Name)) {
leaderName = leader.Name;
} else {
var textEntry = ClientTextProvider.Provider.GetTextEntry(leader.NameTextId);
leaderName = textEntry != null ? textEntry.Text : "Hero";
}
var textEntry = ClientTextProvider.Provider.GetTextEntry(leader.NameTextId);
var leaderName = textEntry != null ? textEntry.Text : "Hero";
leaderField.text = string.Format(
"{0} ({1})",
leaderName,
@@ -44,13 +44,7 @@ namespace eagle {
var wasEmpty = _entries.Count == 0;
_entries = value != null ? value.ToList() : new List<ChronicleEntry>();
if (_entries.Count == 0) {
// Hide canvas if entries are cleared while visible (e.g., during state resync).
// Without this, user could click dismiss while entries are empty, causing
// crash.
if (gameObject.activeSelf) { gameObject.SetActive(false); }
return;
}
if (_entries.Count == 0) return;
// Jump to the last entry when first populating, or if not currently viewing
if (wasEmpty || !gameObject.activeSelf) {
@@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Google.Protobuf;
using Net.Eagle0.Eagle.Api;
using UnityEngine;
namespace eagle {
public class ClientPregeneratedText : MonoBehaviour {
public static ClientPregeneratedText Provider = null;
public void Load(PregeneratedText incomingText) {
var directoryPath = Path.GetDirectoryName(_localPregeneratedTextPath);
Directory.CreateDirectory(directoryPath);
_pregeneratedTexts = incomingText.Entries.ToDictionary(x => x.TextId, x => x.Text);
Hash = incomingText.Hash;
File.WriteAllBytes(_localPregeneratedTextPath, incomingText.ToByteArray());
}
public void Awake() {
_localPregeneratedTextPath = Path.Combine(
Application.persistentDataPath,
"eagle0",
"Resources",
"pregenerated_text.txt");
if (File.Exists(_localPregeneratedTextPath)) {
var data = File.ReadAllBytes(_localPregeneratedTextPath);
var fromFile = PregeneratedText.Parser.ParseFrom(data);
_pregeneratedTexts = fromFile.Entries.ToDictionary(x => x.TextId, x => x.Text);
Hash = fromFile.Hash;
} else {
_pregeneratedTexts = new();
Hash = 0;
}
Provider = this;
}
public Int64 Hash { get; private set; }
public bool TryGetText(string textId, out string text) =>
_pregeneratedTexts.TryGetValue(textId, out text);
private string _localPregeneratedTextPath;
private Dictionary<string, string> _pregeneratedTexts = new();
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 347714dfb2a64d76ac4b4d87060e6159
timeCreated: 1717958518
@@ -104,6 +104,10 @@ namespace eagle {
public TextEntry GetTextEntry(string streamId) {
if (String.IsNullOrEmpty(streamId)) return new TextEntry("", false);
if (ClientPregeneratedText.Provider.TryGetText(streamId, out var text)) {
return new TextEntry(text, true);
}
lock (_lock) {
_streamingTexts.TryGetValue(streamId, out var entry);
return entry;
@@ -2,7 +2,6 @@
using System.Collections.Generic;
using System.Linq;
using common.GUIUtils;
using Eagle0.Tutorial;
using Net.Eagle0.Eagle.Api;
using TMPro;
using UnityEngine;
@@ -29,7 +28,6 @@ namespace eagle {
public TMP_Text headerLabel;
public Button commitCommandButton;
public TextMeshProUGUI commitButtonText;
public Button infoButton;
public IGameModel Model { get; set; }
public ErrorHandler errorHandler;
public CommandWarningPanelController commandWarningPanelController;
@@ -49,17 +47,7 @@ namespace eagle {
if (Selector != null) { Selector.RefreshUI(); }
}
public void UpdateSelectorLayoutMode(LayoutMode mode) {
if (Selector != null && Selector.CurrentLayoutMode != mode) {
Selector.CurrentLayoutMode = mode;
Selector.OnLayoutModeChanged();
}
}
public void SetAvailableCommandAndSelector(AvailableCommand cmd, CommandSelector sel) {
// Check if command help is currently showing before changing selector
bool helpWasShowing = IsCommandHelpShowing();
AvailableCommand = cmd;
Selector = sel;
@@ -70,50 +58,11 @@ namespace eagle {
return;
}
// Pass layout mode to selector before setting up UI
Selector.CurrentLayoutMode = mainController.CurrentLayoutMode;
Selector.UpdateAvailableCommand(Model, AvailableCommand);
headerLabel.text = Selector.HeaderString;
// If help was showing for previous command, show help for new command
if (helpWasShowing) { ShowCurrentCommandHelp(); }
} catch (Exception e) { errorHandler.Add(e); }
}
/// <summary>
/// Returns true if a command help panel is currently showing.
/// </summary>
private bool IsCommandHelpShowing() {
var activeId = TutorialManager.Instance?.ActiveSequenceId;
return activeId != null && activeId.StartsWith("command_");
}
/// <summary>
/// Shows help for the current command (used when switching commands while help is open).
/// </summary>
private void ShowCurrentCommandHelp() {
if (Selector == null) return;
string tutorialId = GetCurrentCommandTutorialId();
if (tutorialId != null) { TutorialManager.Instance?.ShowCommandHelp(tutorialId); }
}
/// <summary>
/// Gets the tutorial ID for the current command.
/// </summary>
private string GetCurrentCommandTutorialId() {
if (Selector == null) return null;
var commandName = Selector.CommandType.ToString();
if (commandName.EndsWith("Command")) {
commandName = commandName.Substring(0, commandName.Length - "Command".Length);
}
var snakeCase = System.Text.RegularExpressions.Regex.Replace(
commandName,
"([a-z])([A-Z])",
"$1_$2");
return $"command_{snakeCase.ToLower()}";
}
public List<CommandSelector> AppropriateSelectors(AvailableCommand cmd) =>
commandSelectors.FindAll(sel => sel.IsAppropriateFor(cmd));
@@ -143,14 +92,6 @@ namespace eagle {
foreach (var kvp in commandSelectors) { kvp.gameObject.SetActive(false); }
}
public void Update() {
// '?' key (Shift + /) toggles help panel when a command is selected
if (Selector != null && Input.GetKeyDown(KeyCode.Slash) &&
(Input.GetKey(KeyCode.LeftShift) || Input.GetKey(KeyCode.RightShift))) {
InfoButtonClicked();
}
}
public void PointerEnteredCommitButton() {
if (Selector != null) {
if (!Selector.EnableCommitButton) {
@@ -173,22 +114,11 @@ namespace eagle {
public void PointerExitedCommitButton() { commandWarningPanelController.ClearWarning(); }
/// <summary>
/// Called when the info button is clicked. Shows help for the current command.
/// </summary>
public void InfoButtonClicked() {
if (Selector == null) return;
string tutorialId = GetCurrentCommandTutorialId();
if (tutorialId != null) { TutorialManager.Instance?.ShowCommandHelp(tutorialId); }
}
public void OnGUI() {
var eventType = Event.current.type;
try {
if (Selector == null) {
commitCommandButton.gameObject.SetActive(false);
if (infoButton != null) { infoButton.gameObject.SetActive(false); }
} else {
bool disableWarningPanel = !commitCommandButton.gameObject.activeSelf;
@@ -201,9 +131,6 @@ namespace eagle {
commitCommandButton.gameObject.SetActive(true);
commitCommandButton.interactable = Selector.EnableCommitButton;
// Show info button when a command is selected
if (infoButton != null) { infoButton.gameObject.SetActive(true); }
if (disableWarningPanel) {
MainQueue.Q.EnqueueForNextUpdate(
() => { commandWarningPanelController.ClearWarning(); });
@@ -45,9 +45,14 @@ namespace eagle {
public Toggle demandTributeToggle;
public Toggle withdrawToggle;
public Toggle safePassageToggle;
public ToggleGroup decisionToggleGroup;
public GameObject tributeContainer;
public GameObject advanceImage;
public GameObject demandTributeImage;
public GameObject withdrawImage;
public GameObject safePassageImage;
public Image goldIcon;
public Image foodIcon;
public Slider goldSlider;
public Slider foodSlider;
public TMP_Text goldLabel;
@@ -135,10 +140,21 @@ namespace eagle {
}
}
ConfigureToggle(advanceToggle, enableAttack);
ConfigureToggle(withdrawToggle, enableWithdraw);
ConfigureToggle(demandTributeToggle, enableDemandTribute);
ConfigureToggle(safePassageToggle, enableSafePassage);
advanceToggle.enabled = enableAttack;
advanceToggle.gameObject.SetActive(enableAttack);
advanceImage.SetActive(enableAttack);
withdrawToggle.enabled = enableWithdraw;
withdrawToggle.gameObject.SetActive(enableWithdraw);
withdrawImage.SetActive(enableWithdraw);
demandTributeToggle.enabled = enableDemandTribute;
demandTributeToggle.gameObject.SetActive(enableDemandTribute);
demandTributeImage.SetActive(enableDemandTribute);
safePassageToggle.enabled = enableSafePassage;
safePassageToggle.gameObject.SetActive(enableSafePassage);
safePassageImage.SetActive(enableSafePassage);
goldSlider.minValue = 0;
goldSlider.maxValue = maxGoldTribute;
@@ -186,7 +202,14 @@ namespace eagle {
foodLabel.text = ((int)newValue).ToString();
}
private void EnableSliders(bool newEnabled) { tributeContainer.SetActive(newEnabled); }
private void EnableSliders(bool newEnabled) {
goldIcon.gameObject.SetActive(newEnabled);
foodIcon.gameObject.SetActive(newEnabled);
goldSlider.gameObject.SetActive(newEnabled);
foodSlider.gameObject.SetActive(newEnabled);
goldLabel.gameObject.SetActive(newEnabled);
foodLabel.gameObject.SetActive(newEnabled);
}
public void ToggleChanged(bool value) {
bool enableSliders =
@@ -195,24 +218,5 @@ namespace eagle {
EnableSliders(enableSliders);
}
private const float DisabledAlpha = 0.35f;
private void ConfigureToggle(Toggle toggle, bool available) {
if (toggle == null) return;
toggle.gameObject.SetActive(true);
toggle.interactable = available;
if (!available) { toggle.isOn = false; }
toggle.group = available ? decisionToggleGroup : null;
var canvasGroup = toggle.GetComponent<CanvasGroup>();
if (canvasGroup == null) {
canvasGroup = toggle.gameObject.AddComponent<CanvasGroup>();
}
canvasGroup.alpha = available ? 1f : DisabledAlpha;
}
}
}
@@ -14,25 +14,6 @@ namespace eagle {
public abstract class CommandSelector : MonoBehaviour {
public Texture ButtonImage;
/// <summary>
/// Current layout mode. Set by CommandPanelController before SetUpUI is called.
/// Selectors can use this to adjust their UI for narrow screens.
/// </summary>
public LayoutMode CurrentLayoutMode { get; set; } = LayoutMode.Medium;
/// <summary>
/// True when in narrow layout mode (iPad-like 4:3 aspect ratios).
/// In narrow mode, selectors should hide hero/battalion pickers since
/// users can select via the Resident Heroes and Battalions panels.
/// </summary>
protected bool IsNarrowLayout => CurrentLayoutMode == LayoutMode.Narrow;
/// <summary>
/// Called when the layout mode changes while this selector is active.
/// Override to update visibility of layout-dependent UI elements.
/// </summary>
public virtual void OnLayoutModeChanged() {}
public abstract AvailableCommand.SealedValueOneofCase CommandType { get; }
public virtual bool IsAppropriateFor(AvailableCommand cmd) => cmd.SealedValueCase
== CommandType;
@@ -108,8 +89,9 @@ namespace eagle {
_model = model;
_availableCommand = ac;
SetUpUI();
// Note: Command tutorials are now shown via info button instead of automatic popups.
// See CommandPanelController.InfoButtonClicked() for the new flow.
// Trigger tutorial for this command type (fires every time command is selected)
TutorialManager.Instance?.TriggerRegistry?.OnCommandPanelShown(CommandType);
}
}
}
@@ -5,7 +5,7 @@ using Net.Eagle0.Eagle.Api;
using Net.Eagle0.Eagle.Api.Command.Util;
using Net.Eagle0.Eagle.Common;
using TMPro;
using UnityEngine;
using UnityEditor;
namespace eagle {
using BattalionId = Int32;
@@ -25,10 +25,6 @@ namespace eagle {
public EventBasedUnitSelector unitSelector;
public TMP_Dropdown rallyPointDropdown;
// Columns to hide in narrow layout mode (users select via side panels instead)
public GameObject heroesColumn;
public GameObject battalionsColumn;
public override AvailableCommand.SealedValueOneofCase CommandType =>
AvailableCommand.SealedValueOneofCase.DefendCommand;
public override string HeaderString => "Defend";
@@ -131,16 +127,7 @@ namespace eagle {
recommendedUnits);
}
public override void OnLayoutModeChanged() {
// Update hero/battalion column visibility when layout changes
if (heroesColumn != null) heroesColumn.SetActive(!IsNarrowLayout);
if (battalionsColumn != null) battalionsColumn.SetActive(!IsNarrowLayout);
}
protected override void SetUpUI() {
// Hide hero/battalion columns in narrow mode - users select via side panels
OnLayoutModeChanged();
if (DefendCommand.AvailableFleeProvinceIds.Count > 0) {
var availableRallyPointProvinceNames =
DefendCommand.AvailableFleeProvinceIds
@@ -32,7 +32,6 @@ namespace eagle {
public Toggle truceToggle;
public Toggle allianceToggle;
public Toggle inviteToggle;
public ToggleGroup optionToggleGroup;
public TMP_Text truceLabel;
public TMP_Text allianceLabel;
@@ -78,13 +77,13 @@ namespace eagle {
private DiplomacyOption.SealedValueOneofCase SelectedDiplomacyOptionType {
get {
if (truceToggle.isOn && truceToggle.interactable && HasTruce())
if (truceToggle.isOn && HasTruce())
return DiplomacyOption.SealedValueOneofCase.TruceOption;
if (allianceToggle.isOn && allianceToggle.interactable && HasAlliance())
if (allianceToggle.isOn && HasAlliance())
return DiplomacyOption.SealedValueOneofCase.AllianceOption;
if (allianceToggle.isOn && allianceToggle.interactable && HasBreakAlliance())
if (allianceToggle.isOn && HasBreakAlliance())
return DiplomacyOption.SealedValueOneofCase.BreakAllianceOption;
if (inviteToggle.isOn && inviteToggle.interactable && HasInvitation())
if (inviteToggle.isOn && HasInvitation())
return DiplomacyOption.SealedValueOneofCase.InvitationOption;
return DiplomacyOption.SealedValueOneofCase.None;
}
@@ -213,25 +212,25 @@ namespace eagle {
private void SetOptions() {
var enableTruce = HasTruce();
ConfigureToggle(truceToggle, truceLabel, enableTruce, "Truce");
ConditionallyEnable(truceToggle, truceLabel, enableTruce);
var enableInvite = HasInvitation();
ConfigureToggle(inviteToggle, inviteLabel, enableInvite, "Invitation");
ConditionallyEnable(inviteToggle, inviteLabel, enableInvite);
var enableAlliance = HasAlliance();
var enableBreakAlliance = HasBreakAlliance();
var allianceLabelText = enableBreakAlliance ? "Break Alliance" : "Alliance";
ConfigureToggle(
allianceToggle,
allianceLabel,
enableAlliance || enableBreakAlliance,
allianceLabelText);
if (enableBreakAlliance) {
allianceLabel.text = "Break Alliance";
ConditionallyEnable(allianceToggle, allianceLabel, true);
} else {
allianceLabel.text = "Alliance";
ConditionallyEnable(allianceToggle, allianceLabel, enableAlliance);
}
// Select the first available option
if (enableTruce && truceToggle.interactable) truceToggle.isOn = true;
else if (enableInvite && inviteToggle.interactable)
if (enableTruce) truceToggle.isOn = true;
else if (enableInvite)
inviteToggle.isOn = true;
else if ((enableAlliance || enableBreakAlliance) && allianceToggle.interactable)
else if (enableAlliance || enableBreakAlliance)
allianceToggle.isOn = true;
else
throw new ArgumentException("No diplomacy options available");
@@ -239,31 +238,11 @@ namespace eagle {
costText.text = SelectedOptionCost.ToString();
}
private const float DisabledAlpha = 0.35f;
private void
ConfigureToggle(Toggle toggle, TMP_Text label, bool available, string labelText) {
if (toggle == null) return;
// Always show the toggle, but disable if not available
toggle.gameObject.SetActive(true);
toggle.interactable = available;
// Turn off unavailable toggles so they don't appear selected
if (!available) { toggle.isOn = false; }
// Only add to toggle group if available
toggle.group = available ? optionToggleGroup : null;
// Gray out unavailable toggles using CanvasGroup
var canvasGroup = toggle.GetComponent<CanvasGroup>();
if (canvasGroup == null) {
canvasGroup = toggle.gameObject.AddComponent<CanvasGroup>();
}
canvasGroup.alpha = available ? 1f : DisabledAlpha;
// Update label text
if (label != null) { label.text = labelText; }
private void ConditionallyEnable(Toggle toggle, TMP_Text label, bool enable) {
toggle.gameObject.SetActive(enable);
Color color = label.color;
color.a = enable ? 1.0f : 0.25f;
label.color = color;
}
private int SelectedOptionCost =>
@@ -23,7 +23,6 @@ namespace eagle {
public Toggle AdvanceToggle;
public Toggle WithdrawToggle;
public ToggleGroup decisionToggleGroup;
private FreeForAllDecisionAvailableCommand FreeForAllDecisionCommand =>
_availableCommand.FreeForAllDecisionCommand;
@@ -64,32 +63,10 @@ namespace eagle {
row.Origin = _model.Provinces[army.OriginProvinceId].Name;
});
bool withdrawAvailable = FreeForAllDecisionCommand.AvailableDecisions.Any(
x => x.SealedValueCase == AttackDecisionType.SealedValueOneofCase.Withdraw);
ConfigureToggle(AdvanceToggle, true);
ConfigureToggle(WithdrawToggle, withdrawAvailable);
AdvanceToggle.isOn = true;
}
private const float DisabledAlpha = 0.35f;
private void ConfigureToggle(Toggle toggle, bool available) {
if (toggle == null) return;
toggle.gameObject.SetActive(true);
toggle.interactable = available;
if (!available) { toggle.isOn = false; }
toggle.group = available ? decisionToggleGroup : null;
var canvasGroup = toggle.GetComponent<CanvasGroup>();
if (canvasGroup == null) {
canvasGroup = toggle.gameObject.AddComponent<CanvasGroup>();
}
canvasGroup.alpha = available ? 1f : DisabledAlpha;
AdvanceToggle.enabled = true;
WithdrawToggle.enabled = FreeForAllDecisionCommand.AvailableDecisions.Any(
x => x.SealedValueCase == AttackDecisionType.SealedValueOneofCase.Withdraw);
}
// Use this for initialization
@@ -15,7 +15,12 @@ namespace eagle {
public Toggle ExileToggle;
public Toggle ExecuteToggle;
public Toggle ReturnToggle;
public ToggleGroup decisionToggleGroup;
public RawImage RecruitIcon;
public RawImage ImprisonIcon;
public RawImage ExileIcon;
public RawImage ExecuteIcon;
public RawImage ReturnIcon;
public HeroDetailsController heroDetails;
@@ -90,54 +95,51 @@ namespace eagle {
messageUpdater.TextId = SelectedHeroWithOptions.MessageId;
var options = SelectedHeroWithOptions.Options;
ConfigureToggle(
RecruitToggle,
options.Contains(CapturedHeroOption.RecruitCapturedHeroOption));
ConfigureToggle(
ImprisonToggle,
options.Contains(CapturedHeroOption.ImprisonCapturedHeroOption));
ConfigureToggle(
ExileToggle,
options.Contains(CapturedHeroOption.ExileCapturedHeroOption));
ConfigureToggle(
// The order here is important: the last available toggle in this list
// will be on by default
ConditionallyEnable(
ExecuteToggle,
options.Contains(CapturedHeroOption.ExecuteCapturedHeroOption));
ConfigureToggle(
ExecuteIcon,
CapturedHeroOption.ExecuteCapturedHeroOption);
ConditionallyEnable(ExileToggle, ExileIcon, CapturedHeroOption.ExileCapturedHeroOption);
ConditionallyEnable(
ReturnToggle,
options.Contains(CapturedHeroOption.ReturnCapturedHeroOption));
// Select first available option (priority order: Recruit > Imprison > Exile > Execute >
// Return)
if (options.Contains(CapturedHeroOption.RecruitCapturedHeroOption))
RecruitToggle.isOn = true;
else if (options.Contains(CapturedHeroOption.ImprisonCapturedHeroOption))
ImprisonToggle.isOn = true;
else if (options.Contains(CapturedHeroOption.ExileCapturedHeroOption))
ExileToggle.isOn = true;
else if (options.Contains(CapturedHeroOption.ExecuteCapturedHeroOption))
ExecuteToggle.isOn = true;
else if (options.Contains(CapturedHeroOption.ReturnCapturedHeroOption))
ReturnToggle.isOn = true;
ReturnIcon,
CapturedHeroOption.ReturnCapturedHeroOption);
ConditionallyEnable(
ImprisonToggle,
ImprisonIcon,
CapturedHeroOption.ImprisonCapturedHeroOption);
ConditionallyEnable(
RecruitToggle,
RecruitIcon,
CapturedHeroOption.RecruitCapturedHeroOption);
}
private const float DisabledAlpha = 0.35f;
private bool ConditionallyEnable(Toggle toggle, CapturedHeroOption option) {
bool enable = SelectedHeroWithOptions.Options.Contains(option);
toggle.gameObject.SetActive(enable);
private void ConfigureToggle(Toggle toggle, bool available) {
if (toggle == null) return;
if (enable) {
ExecuteToggle.isOn = false;
ExileToggle.isOn = false;
ImprisonToggle.isOn = false;
RecruitToggle.isOn = false;
ReturnToggle.isOn = false;
toggle.gameObject.SetActive(true);
toggle.interactable = available;
toggle.isOn = true;
} else
toggle.isOn = false;
if (!available) { toggle.isOn = false; }
return enable;
}
toggle.group = available ? decisionToggleGroup : null;
private void ConditionallyEnable(Toggle toggle, RawImage img, CapturedHeroOption option) {
bool enable = ConditionallyEnable(toggle, option);
var canvasGroup = toggle.GetComponent<CanvasGroup>();
if (canvasGroup == null) {
canvasGroup = toggle.gameObject.AddComponent<CanvasGroup>();
}
canvasGroup.alpha = available ? 1f : DisabledAlpha;
Color color = img.color;
color.a = enable ? 1.0f : 0.25f;
img.color = color;
}
}
}
@@ -1,10 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Net.Eagle0.Eagle.Api;
using Net.Eagle0.Eagle.Api.Command.Util;
using Net.Eagle0.Eagle.Common;
using Net.Eagle0.Eagle.Views;
using TMPro;
using UnityEngine;
@@ -21,7 +19,12 @@ namespace eagle {
public Toggle ExileToggle;
public Toggle ExecuteToggle;
public Toggle ReturnToggle;
public ToggleGroup decisionToggleGroup;
public RawImage ReleaseIcon;
public RawImage MoveIcon;
public RawImage ExileIcon;
public RawImage ExecuteIcon;
public RawImage ReturnIcon;
public TMP_Dropdown moveToDropdown;
@@ -71,33 +74,6 @@ namespace eagle {
public override string CommitWarningText =>
$"This will create a treaty with {_model.FactionName(SelectedHero.FactionId.Value)}";
private List<PrisonerManagementOption> MoveOptions =>
SelectedPrisonerToManage.AvailableOptions
.Where(opt => opt.SealedValueCase ==
PrisonerManagementOption.SealedValueOneofCase.Move)
.ToList();
protected override ProvinceId? TargetedProvince {
get {
if (MoveToggle.isOn && moveToDropdown.value >= 0 &&
moveToDropdown.value < MoveOptions.Count) {
return MoveOptions[moveToDropdown.value].Move.ToProvinceId;
}
return null;
}
set {
if (!value.HasValue) return;
var moveOptions = MoveOptions;
int index = moveOptions.FindIndex(opt => opt.Move.ToProvinceId == value.Value);
if (index != -1) {
MoveToggle.isOn = true;
moveToDropdown.value = index;
moveToDropdown.gameObject.SetActive(true);
}
}
}
private PrisonerManagementOption SelectedOption {
get {
if (ExecuteToggle.isOn)
@@ -163,122 +139,62 @@ namespace eagle {
.Select(name => new TMP_Dropdown.OptionData(name))
.ToList();
MessageText.text = GetQuestMessagesForPrisoner(SelectedHero.Id);
MessageText.text = "";
var options = SelectedPrisonerToManage.AvailableOptions;
bool hasRelease = options.Any(
opt => opt.SealedValueCase ==
PrisonerManagementOption.SealedValueOneofCase.Release);
bool hasMove = options.Any(
opt => opt.SealedValueCase ==
PrisonerManagementOption.SealedValueOneofCase.Move);
bool hasExile = options.Any(
opt => opt.SealedValueCase ==
PrisonerManagementOption.SealedValueOneofCase.Exile);
bool hasExecute = options.Any(
opt => opt.SealedValueCase ==
PrisonerManagementOption.SealedValueOneofCase.Execute);
bool hasReturn = options.Any(
opt => opt.SealedValueCase ==
PrisonerManagementOption.SealedValueOneofCase.Return);
ConfigureToggle(ReleaseToggle, hasRelease);
ConfigureToggle(MoveToggle, hasMove);
ConfigureToggle(ExileToggle, hasExile);
ConfigureToggle(ExecuteToggle, hasExecute);
ConfigureToggle(ReturnToggle, hasReturn);
// Select first available option (priority order: Release > Move > Exile > Execute >
// Return)
if (hasRelease) ReleaseToggle.isOn = true;
else if (hasMove)
MoveToggle.isOn = true;
else if (hasExile)
ExileToggle.isOn = true;
else if (hasExecute)
ExecuteToggle.isOn = true;
else if (hasReturn)
ReturnToggle.isOn = true;
// Update dropdown visibility based on selected toggle
moveToDropdown.gameObject.SetActive(MoveToggle.isOn);
// The order here is important: the last available toggle in this list
// will be on by default
ConditionallyEnable(
ExecuteToggle,
ExecuteIcon,
PrisonerManagementOption.SealedValueOneofCase.Execute);
ConditionallyEnable(
ExileToggle,
ExileIcon,
PrisonerManagementOption.SealedValueOneofCase.Exile);
ConditionallyEnable(
ReturnToggle,
ReturnIcon,
PrisonerManagementOption.SealedValueOneofCase.Return);
ConditionallyEnable(
MoveToggle,
MoveIcon,
PrisonerManagementOption.SealedValueOneofCase.Move);
ConditionallyEnable(
ReleaseToggle,
ReleaseIcon,
PrisonerManagementOption.SealedValueOneofCase.Release);
}
private string GetQuestMessagesForPrisoner(HeroId prisonerId) {
var questMessages = new List<string>();
private bool ConditionallyEnable(
Toggle toggle,
PrisonerManagementOption.SealedValueOneofCase option) {
bool enable = SelectedPrisonerToManage.AvailableOptions.Any(
opt => opt.SealedValueCase == option);
toggle.gameObject.SetActive(enable);
foreach (var province in _model.Provinces.Values) {
if (province.FullInfo?.UnaffiliatedHeroes == null) continue;
if (enable) {
ExecuteToggle.isOn = false;
ExileToggle.isOn = false;
MoveToggle.isOn = false;
ReleaseToggle.isOn = false;
ReturnToggle.isOn = false;
foreach (var uh in province.FullInfo.UnaffiliatedHeroes) {
if (uh.RecruitmentInfo?.Status != RecruitmentStatus.HasQuest) continue;
var quest = uh.RecruitmentInfo.Quest;
if (quest?.Details == null) continue;
toggle.isOn = true;
} else
toggle.isOn = false;
var (matches, actionVerb) = GetPrisonerQuestInfo(quest.Details, prisonerId);
if (!matches) continue;
var heroName = GetHeroDisplayName(uh.HeroId);
questMessages.Add($"{heroName} wants you to {actionVerb} this prisoner.");
}
}
return questMessages.Count > 0 ? string.Join("\n", questMessages) : "";
return enable;
}
private (bool matches, string actionVerb)
GetPrisonerQuestInfo(QuestDetails details, HeroId prisonerId) {
switch (details.SealedValueCase) {
case QuestDetails.SealedValueOneofCase.ExecutePrisonerQuest:
if (details.ExecutePrisonerQuest.PrisonerHeroId == prisonerId) {
return (true, "execute");
}
break;
case QuestDetails.SealedValueOneofCase.ExilePrisonerQuest:
if (details.ExilePrisonerQuest.PrisonerHeroId == prisonerId) {
return (true, "exile");
}
break;
case QuestDetails.SealedValueOneofCase.FreePrisonerQuest:
if (details.FreePrisonerQuest.PrisonerHeroId == prisonerId) {
return (true, "release");
}
break;
case QuestDetails.SealedValueOneofCase.ReturnPrisonerQuest:
if (details.ReturnPrisonerQuest.PrisonerHeroId == prisonerId) {
var factionName =
_model.FactionName(details.ReturnPrisonerQuest.ToFactionId);
return (true, $"return to {factionName}");
}
break;
}
return (false, null);
}
private void ConditionallyEnable(
Toggle toggle,
RawImage img,
PrisonerManagementOption.SealedValueOneofCase option) {
bool enable = ConditionallyEnable(toggle, option);
private string GetHeroDisplayName(HeroId heroId) {
if (!_model.Heroes.TryGetValue(heroId, out var hero)) { return "A free hero"; }
var textEntry = ClientTextProvider.Provider.GetTextEntry(hero.NameTextId);
return textEntry != null && !string.IsNullOrEmpty(textEntry.Text) ? textEntry.Text
: "A free hero";
}
private const float DisabledAlpha = 0.35f;
private void ConfigureToggle(Toggle toggle, bool available) {
if (toggle == null) return;
toggle.gameObject.SetActive(true);
toggle.interactable = available;
if (!available) { toggle.isOn = false; }
toggle.group = available ? decisionToggleGroup : null;
var canvasGroup = toggle.GetComponent<CanvasGroup>();
if (canvasGroup == null) {
canvasGroup = toggle.gameObject.AddComponent<CanvasGroup>();
}
canvasGroup.alpha = available ? 1f : DisabledAlpha;
Color color = img.color;
color.a = enable ? 1.0f : 0.25f;
img.color = color;
}
}
}
@@ -7,7 +7,6 @@ using Net.Eagle0.Eagle.Api.Command.Util;
using Net.Eagle0.Eagle.Common;
using Net.Eagle0.Eagle.Views;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace eagle {
@@ -39,11 +38,6 @@ namespace eagle {
public EventBasedUnitSelector unitSelector;
public TMP_Dropdown fromDropdown;
public TMP_Dropdown toDropdown;
// Columns to show/hide based on layout mode
public GameObject heroesColumn;
public GameObject battalionsColumn;
public GameObject battalionsColumnNarrow; // Alternate battalions view for narrow layout
public Slider goldSlider;
public Slider foodSlider;
public TMP_Text goldTakenLabel;
@@ -127,12 +121,9 @@ namespace eagle {
DestinationProvince is { RulingFactionId : not null } &&
DestinationProvince.RulingFactionId.Value != _playerId;
// Only warn about river crossing if destination is NOT owned by the same faction
// (friendly territory doesn't require assault)
private bool RequiresRiverCrossing => SelectedDestinationProvinceIndex is {}
idx && _sortedAvailableDestinations != null && idx < _sortedAvailableDestinations.Count &&
_sortedAvailableDestinations[idx].RequiresRiverCrossing &&
DestinationRulingFactionId != _playerId;
_sortedAvailableDestinations[idx].RequiresRiverCrossing;
public override bool WarnOnCommitButton =>
WouldAbandonProvince || NotEnoughFood || MarchingOnTrucePartner ||
@@ -302,18 +293,7 @@ namespace eagle {
}
}
public override void OnLayoutModeChanged() {
// Heroes always visible (user may march from different province than the viewed one)
if (heroesColumn != null) heroesColumn.SetActive(true);
// Toggle between wide and narrow battalion views
if (battalionsColumn != null) battalionsColumn.SetActive(!IsNarrowLayout);
if (battalionsColumnNarrow != null) battalionsColumnNarrow.SetActive(IsNarrowLayout);
}
protected override void SetUpUI() {
// Hide hero/battalion columns in narrow mode - users select via side panels
OnLayoutModeChanged();
_sortedOneProvinceCommands =
MarchAvailableCommand.OneProvinceCommands
.OrderBy(cmd => _model.Provinces[cmd.OriginProvinceId].Name)
@@ -217,7 +217,6 @@ namespace eagle {
public EagleCommonTextures textures;
public TMP_Text costLabel;
public TMP_Text costWarningLabel;
public TMP_Text troopCountLabel;
private bool TypeIsAllowed(BattalionTypeId battalionTypeId) {
@@ -740,10 +739,9 @@ namespace eagle {
var cost = Cost();
bool sufficient = cost <= availableGold;
costLabel.text = cost.ToString();
if (costWarningLabel != null) {
costWarningLabel.text = sufficient ? "" : $"(only {availableGold} available)";
}
var costText =
$"Total cost: {cost}\n({(sufficient ? "" : "only ")}{availableGold} available)";
costLabel.text = costText;
troopCountLabel.text = TroopCount().ToString();
@@ -20,42 +20,9 @@ namespace eagle {
public override string HeaderString =>
$"Suppress {DisplayNames.Capitalized(BeastTypeName)}";
public override string CommitButtonString => "Commit Suppression";
public override bool WarnOnCommitButton =>
!SelectedBattalionId.HasValue || IsSendingFactionLeader;
public override string CommitWarningText {
get {
if (!SelectedBattalionId.HasValue && IsSendingFactionLeader) {
return $"You aren't sending a battalion, and you're risking your {FactionLeaderTitle}!";
}
if (!SelectedBattalionId.HasValue) {
return "You aren't sending a battalion. Your hero may be killed.";
}
if (IsSendingFactionLeader) { return $"You're risking your {FactionLeaderTitle}!"; }
return null;
}
}
private string FactionLeaderTitle {
get {
if (_model?.Heroes == null ||
!_model.Heroes.TryGetValue(SelectedHeroId, out var hero)) {
return "faction leader";
}
return $"sworn {DisplayNames.SiblingDescription(hero.PronounGender)}";
}
}
private bool IsSendingFactionLeader {
get {
if (_model?.PlayerId == null) return false;
if (!_model.ActiveFactions.TryGetValue(
_model.PlayerId.Value,
out var playerFaction)) {
return false;
}
return SelectedHeroId == playerFaction.FactionHeadId;
}
}
public override bool WarnOnCommitButton => !SelectedBattalionId.HasValue;
public override string CommitWarningText =>
"You aren't sending a battalion. Your hero may be killed.";
public override List<HeroId> TargetedHeroIds => new List<HeroId> { SelectedHeroId };
public override bool HeroIsTargetable(HeroId heroId) =>
@@ -15,7 +15,6 @@ namespace eagle {
public TMP_Text goldText;
public TMP_Text foodText;
public TMP_Text description;
public RectTransform startingPositionIndicator;
public override AvailableCommand.SealedValueOneofCase CommandType =>
AvailableCommand.SealedValueOneofCase.TradeCommand;
@@ -41,17 +40,6 @@ namespace eagle {
buyPrice.text = $"{TradeCommand.FoodBuyPrice:0.##}";
sellPrice.text = $"{TradeCommand.FoodSellPrice:0.##}";
// Position the starting position indicator horizontally
if (startingPositionIndicator != null && MaxFood > 0) {
float normalizedStart = (float)TradeCommand.FoodAvailable / MaxFood;
// Only adjust horizontal anchor, preserve vertical anchors from prefab
startingPositionIndicator.anchorMin =
new Vector2(normalizedStart, startingPositionIndicator.anchorMin.y);
startingPositionIndicator.anchorMax =
new Vector2(normalizedStart, startingPositionIndicator.anchorMax.y);
startingPositionIndicator.anchoredPosition = Vector2.zero;
}
SetTextFields();
}
@@ -16,7 +16,6 @@ namespace eagle {
public class EventBasedUnitSelector : MonoBehaviour {
public GameObject availableHeroesContent;
public GameObject availableBattalionsContent;
public GameObject availableBattalionsContentNarrow; // Optional alternate for narrow layout
public GameObject selectedUnitsContent;
public GameObject availableHeroRow;
@@ -199,20 +198,10 @@ namespace eagle {
if (i % 2 == 1) { newRow.GetComponent<Image>().color = oddColor; }
}
PopulateBattalionsContent(availableBattalionsContent, oddColor);
if (availableBattalionsContentNarrow != null) {
PopulateBattalionsContent(availableBattalionsContentNarrow, oddColor);
foreach (Transform row in availableBattalionsContent.transform) {
Destroy(row.gameObject);
}
heroesAndBattalionsPanelController.UpdateTables();
selectionChanged.Invoke();
}
private void PopulateBattalionsContent(GameObject content, Color oddColor) {
foreach (Transform row in content.transform) { Destroy(row.gameObject); }
var parentTransform = content.transform;
parentTransform = availableBattalionsContent.transform;
for (int i = 0; i < AvailableBattalions.Count; i++) {
var battalion = AvailableBattalions[i];
@@ -228,6 +217,10 @@ namespace eagle {
if (i % 2 == 1) { newRow.GetComponent<Image>().color = oddColor; }
}
heroesAndBattalionsPanelController.UpdateTables();
selectionChanged.Invoke();
}
public void availableHeroButtonClicked(int index) {
@@ -20,13 +20,6 @@ namespace eagle {
public accessor ConditionAccessor = hero => hero.Vigor;
public string ConditionName = "VIG";
/// <summary>
/// Optional dictionary of pre-resolved names keyed by NameTextId.
/// Set this before setting AvailableHeroes to use resolved names directly
/// instead of looking them up via ClientTextProvider.
/// </summary>
public Dictionary<string, string> PreResolvedNames { get; set; } = new();
private List<HeroView> _availableHeroes;
public List<HeroView> AvailableHeroes {
get => _availableHeroes;
@@ -39,28 +32,17 @@ namespace eagle {
listeners = new List<SingleEntryListener>();
for (int i = 0; i < _availableHeroes.Count; i++) {
HeroView hero = _availableHeroes[i];
var suffix =
var listener = new SingleEntryListener(
hero.NameTextId,
"",
showCondition
? $" ({GUIUtils.ConditionString(ConditionAccessor(hero), ConditionName)})"
: "";
: "",
i,
SetGeneratedText);
// If we have a pre-resolved name, use it directly
if (!string.IsNullOrEmpty(hero.NameTextId) &&
PreResolvedNames.TryGetValue(hero.NameTextId, out var resolvedName) &&
!string.IsNullOrEmpty(resolvedName)) {
SetGeneratedText(i, resolvedName + suffix);
} else {
// Fall back to listener-based resolution
var listener = new SingleEntryListener(
hero.NameTextId,
"",
suffix,
i,
SetGeneratedText);
listeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
}
listeners.Add(listener);
ClientTextProvider.Provider.AddListener(listener);
}
SetHeadshot();
@@ -144,7 +144,7 @@ namespace eagle {
var serverStatus = _gameStateProvider.ServerStatus;
if (serverStatus == null) {
// No server status yet - waiting for first response
return "<color=yellow>●</color> Waiting for server...";
return "<color=green>●</color> Connected";
}
return serverStatus.Status switch {
@@ -155,8 +155,6 @@ namespace eagle {
"<color=green>●</color> Generating...",
ServerGameStatus.Types.Status.ProcessingAction =>
"<color=green>●</color> Processing...",
ServerGameStatus.Types.Status.BattleInProgress =>
"<color=green>●</color> Battle in progress",
_ => "<color=green>●</color> Connected"
};
}
@@ -184,12 +182,6 @@ namespace eagle {
private void UpdateEnvironmentIndicator() {
if (environmentIndicatorText == null) return;
// Only show environment indicator in Unity Editor
if (!Application.isEditor) {
environmentIndicatorText.text = "";
return;
}
if (string.IsNullOrEmpty(_environmentName)) {
environmentIndicatorText.text = "";
return;

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