mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 05:15:44 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0817c9652 |
@@ -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
@@ -1 +1 @@
|
||||
8.5.1
|
||||
7.6.1
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 .
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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)],
|
||||
|
||||
Generated
+4524
-1075
File diff suppressed because it is too large
Load Diff
+2
-23
@@ -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",
|
||||
)
|
||||
|
||||
@@ -4,4 +4,4 @@ set -e
|
||||
set -x
|
||||
|
||||
bazel test \
|
||||
//src/main/scala/...
|
||||
//src/test/scala/...
|
||||
|
||||
@@ -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,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/"
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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"
|
||||
@@ -2,9 +2,6 @@
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
# Use runner-specific build directory if EAGLE0_BUILD_DIR is set, otherwise default
|
||||
BUILD_BASE="${EAGLE0_BUILD_DIR:-/tmp/eagle0}"
|
||||
|
||||
COMMIT=$(/usr/bin/git rev-parse --short HEAD)
|
||||
|
||||
/bin/echo "build protos"
|
||||
@@ -16,7 +13,7 @@ COMMIT=$(/usr/bin/git rev-parse --short HEAD)
|
||||
git log -3
|
||||
|
||||
/bin/echo "build Mac"
|
||||
LOG_PATH="${BUILD_BASE}/editor_mac.log"
|
||||
LOG_PATH="/tmp/eagle0/editor_mac.log"
|
||||
BUILD_DIR=$1
|
||||
|
||||
./ci/github_actions/build_mac.sh "$BUILD_DIR" "$LOG_PATH"
|
||||
|
||||
@@ -2,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
|
||||
|
||||
@@ -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
|
||||
@@ -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."
|
||||
@@ -0,0 +1 @@
|
||||
UNITY_VERSION='6000.3.0f1'
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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?
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Vendored
-5
@@ -1,5 +0,0 @@
|
||||
# Export BUILD files for use with http_archive build_file attribute
|
||||
exports_files([
|
||||
"BUILD.gtl",
|
||||
"BUILD.llvm_mingw",
|
||||
])
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+2
-49
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
7.7.1
|
||||
@@ -1,2 +0,0 @@
|
||||
# Bazel symlinks
|
||||
bazel-*
|
||||
@@ -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),
|
||||
)
|
||||
Generated
-451
@@ -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"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
+6
-6
@@ -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
-1
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 568267bd7b6fa4581adaa40faa984594
|
||||
guid: cf11bfc3cc63e42a4a194dc4571dee36
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
|
||||
+1
-1
@@ -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
|
||||
+14
-14
@@ -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
|
||||
|
||||
-6
@@ -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;
|
||||
}
|
||||
|
||||
+31
-85
@@ -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) {
|
||||
|
||||
+452
-427
File diff suppressed because it is too large
Load Diff
-5
@@ -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);
|
||||
|
||||
+5
-6
@@ -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() {
|
||||
|
||||
+2
-8
@@ -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,
|
||||
|
||||
+98
-40
@@ -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
|
||||
|
||||
+2
-8
@@ -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,
|
||||
|
||||
+1
-7
@@ -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) {
|
||||
|
||||
+50
@@ -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();
|
||||
}
|
||||
}
|
||||
+3
@@ -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;
|
||||
|
||||
-73
@@ -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(); });
|
||||
|
||||
+30
-26
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-21
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-14
@@ -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
|
||||
|
||||
+21
-42
@@ -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 =>
|
||||
|
||||
+3
-26
@@ -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
|
||||
|
||||
+42
-40
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+53
-137
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-21
@@ -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)
|
||||
|
||||
+3
-5
@@ -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();
|
||||
|
||||
|
||||
+3
-36
@@ -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) =>
|
||||
|
||||
-12
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+7
-14
@@ -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) {
|
||||
|
||||
+8
-26
@@ -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
Reference in New Issue
Block a user