mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 10:15:43 +00:00
Compare commits
175
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5aed72b06 | ||
|
|
0438ffea63 | ||
|
|
d3ecaaf908 | ||
|
|
60ee36017c | ||
|
|
7e7821ab55 | ||
|
|
6304d5e3f9 | ||
|
|
15f32ed0e2 | ||
|
|
37d07b2bfa | ||
|
|
eead7bddfd | ||
|
|
18a0c573aa | ||
|
|
f10fd322e5 | ||
|
|
a60e64f1bf | ||
|
|
f069475f99 | ||
|
|
f40a860c93 | ||
|
|
a3d64a17a2 | ||
|
|
49ac693501 | ||
|
|
be4519b51e | ||
|
|
1eaa70fa47 | ||
|
|
3f685b05f5 | ||
|
|
85a7b2669f | ||
|
|
228b63e2e0 | ||
|
|
8fab725ac8 | ||
|
|
71298e2cce | ||
|
|
1c36b75201 | ||
|
|
3b393ea84e | ||
|
|
e4bcbc2083 | ||
|
|
70c4c0025e | ||
|
|
75e32393b3 | ||
|
|
79fa0bbee0 | ||
|
|
9433577fc3 | ||
|
|
c7b26828d6 | ||
|
|
672adc2554 | ||
|
|
4fb50142ff | ||
|
|
1129d81435 | ||
|
|
ff9c455e81 | ||
|
|
a6b0c78d48 | ||
|
|
5f44a79b18 | ||
|
|
62a1aaff07 | ||
|
|
52498bff60 | ||
|
|
760435183b | ||
|
|
599ae2db7f | ||
|
|
1fccff00ff | ||
|
|
2f8468af4d | ||
|
|
d9aa51d368 | ||
|
|
bb3ed2bd17 | ||
|
|
1ee0b3283b | ||
|
|
c240778819 | ||
|
|
08072b2a3b | ||
|
|
04b6f21cd3 | ||
|
|
5c8b324dc8 | ||
|
|
f8e8ca4fab | ||
|
|
3bf44093a5 | ||
|
|
9a7dd953f9 | ||
|
|
9171222a45 | ||
|
|
921b11c075 | ||
|
|
53446667f9 | ||
|
|
c3ad03c270 | ||
|
|
5389c2ca19 | ||
|
|
8336db7b29 | ||
|
|
77791da7c7 | ||
|
|
7bef82a419 | ||
|
|
558b2e54d6 | ||
|
|
9e464eac2c | ||
|
|
8aebf6bbc8 | ||
|
|
fbbee0e9ee | ||
|
|
ac0cc60fc2 | ||
|
|
75fed990ca | ||
|
|
38a07b6373 | ||
|
|
49c458bc5d | ||
|
|
2e4d458759 | ||
|
|
569194a68d | ||
|
|
d39aa53a76 | ||
|
|
43d56ee5a4 | ||
|
|
17441c4cdb | ||
|
|
6bdb6e4aa9 | ||
|
|
3b379e55bc | ||
|
|
e99df3b900 | ||
|
|
335500a145 | ||
|
|
f84bf524f1 | ||
|
|
e7a97990d6 | ||
|
|
3907f59c60 | ||
|
|
e72e79c96c | ||
|
|
173dfbafd9 | ||
|
|
18dd663b4e | ||
|
|
171d4fd333 | ||
|
|
a9b8e27e2a | ||
|
|
3e00e01c1f | ||
|
|
412207c4d4 | ||
|
|
f128f7ea3c | ||
|
|
06b5a15f0c | ||
|
|
25c85e4524 | ||
|
|
e24dace474 | ||
|
|
eb558ee8b8 | ||
|
|
9616f31cfc | ||
|
|
cf18884cc7 | ||
|
|
ce3ed26cc2 | ||
|
|
b928c3779e | ||
|
|
6b90112568 | ||
|
|
d2cb5328cc | ||
|
|
f090efe04a | ||
|
|
291026e044 | ||
|
|
173569f82a | ||
|
|
a37d540411 | ||
|
|
14a2eab47d | ||
|
|
481f42d01c | ||
|
|
49af7c7524 | ||
|
|
b6dcc48bdd | ||
|
|
7d5320220c | ||
|
|
3eda670759 | ||
|
|
ea79b80d7b | ||
|
|
2da74769b9 | ||
|
|
bb52212f9f | ||
|
|
81e8aad93b | ||
|
|
f0bb59b5e5 | ||
|
|
ff09bda820 | ||
|
|
09919e63eb | ||
|
|
65d5e6867e | ||
|
|
f6c52ad5ab | ||
|
|
3f1f9ebf07 | ||
|
|
9c279d85bd | ||
|
|
e91e349d76 | ||
|
|
358086154d | ||
|
|
7a6e871914 | ||
|
|
287b31f4c2 | ||
|
|
18c64713bc | ||
|
|
cb19483f34 | ||
|
|
19e2f22b57 | ||
|
|
d8d3cb1317 | ||
|
|
a999f8b936 | ||
|
|
5035fe1a70 | ||
|
|
1c1066c1d8 | ||
|
|
d3d72ff8dc | ||
|
|
149872bea7 | ||
|
|
0216ceb26b | ||
|
|
5b840a4523 | ||
|
|
a51cad0ca0 | ||
|
|
04928f0e2c | ||
|
|
132abfa7a4 | ||
|
|
bbe301bc04 | ||
|
|
138e7d89f3 | ||
|
|
d858f8243d | ||
|
|
5572a806af | ||
|
|
1b153fbad6 | ||
|
|
e53b899d7c | ||
|
|
89c8fea438 | ||
|
|
7e19476167 | ||
|
|
54cd089106 | ||
|
|
a6a0d58d20 | ||
|
|
c2f1064742 | ||
|
|
7173cf8faa | ||
|
|
44dde46e39 | ||
|
|
4c74265447 | ||
|
|
30fd79f4ff | ||
|
|
53e1dd623b | ||
|
|
1604343975 | ||
|
|
aa7d07d7c6 | ||
|
|
0042d56386 | ||
|
|
0bb61da38c | ||
|
|
5dcf3b9c17 | ||
|
|
8814f2a955 | ||
|
|
c0b2e41212 | ||
|
|
902a65be08 | ||
|
|
ac66d372f8 | ||
|
|
14308026ea | ||
|
|
4b7a1791e0 | ||
|
|
a6a3e26853 | ||
|
|
dc1c9e8284 | ||
|
|
21eb5ff79a | ||
|
|
2cd0583bb0 | ||
|
|
e45ad8fc14 | ||
|
|
d41535ffc0 | ||
|
|
c0eff139bf | ||
|
|
4338600896 | ||
|
|
1ac7477597 | ||
|
|
23cad2f383 |
@@ -10,3 +10,4 @@
|
||||
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,10 +27,12 @@ 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)"' | \
|
||||
sort -rn | head -20 | \
|
||||
--paginate -q '.artifacts[] | "\(.size_in_bytes)\t\(.name)\t\(.created_at)"' > /tmp/artifacts.txt
|
||||
sort -rn /tmp/artifacts.txt | head -20 | \
|
||||
awk -F'\t' '{printf "%d MB\t%s\t%s\n", $1/1024/1024, $2, $3}'
|
||||
rm -f /tmp/artifacts.txt
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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 .
|
||||
@@ -217,7 +217,7 @@ jobs:
|
||||
# Copy files
|
||||
scp -i ~/.ssh/deploy_key docker-compose.prod.yml deploy@"$DO_DROPLET_IP":/opt/eagle0/
|
||||
scp -i ~/.ssh/deploy_key nginx/nginx.conf deploy@"$DO_DROPLET_IP":/opt/eagle0/nginx/
|
||||
scp -i ~/.ssh/deploy_key scripts/deploy-blue-green.sh scripts/warmup-eagle.sh scripts/eagle-exec.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/
|
||||
scp -i ~/.ssh/deploy_key scripts/deploy-blue-green.sh scripts/warmup-eagle.sh scripts/eagle-exec.sh scripts/eagle-logs.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/
|
||||
scp -i ~/.ssh/deploy_key scripts/bin/warmup deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/bin/
|
||||
|
||||
- name: Deploy to production droplet
|
||||
@@ -374,8 +374,6 @@ jobs:
|
||||
# Note: Auth is deployed separately via auth_build.yml - do NOT touch auth here
|
||||
chmod +x /opt/eagle0/scripts/*.sh
|
||||
[ -f "/opt/eagle0/scripts/bin/warmup" ] && chmod +x /opt/eagle0/scripts/bin/warmup
|
||||
# Create eagle-exec alias (symlink in /usr/local/bin)
|
||||
sudo ln -sf /opt/eagle0/scripts/eagle-exec.sh /usr/local/bin/eagle-exec
|
||||
GIT_SHA=\$(echo "\${EAGLE_IMAGE}" | sed 's/.*://')
|
||||
/opt/eagle0/scripts/deploy-blue-green.sh "\${GIT_SHA}"
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
name: iOS Addressables Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- ".github/workflows/ios_addressables_build.yml"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/AddressableAssetsData/**"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Music/**"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Editor/BuildScript.cs"
|
||||
- "ci/github_actions/build_ios_addressables.sh"
|
||||
- "ci/github_actions/upload_addressables.sh"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/ios_addressables_build.yml"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/AddressableAssetsData/**"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Music/**"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Editor/BuildScript.cs"
|
||||
- "ci/github_actions/build_ios_addressables.sh"
|
||||
- "ci/github_actions/upload_addressables.sh"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# Runner-specific build directory to allow parallel builds on multiple runners
|
||||
EAGLE0_BUILD_DIR: /tmp/eagle0-${{ github.run_id }}
|
||||
|
||||
jobs:
|
||||
build-ios-addressables:
|
||||
runs-on: [self-hosted, macOS, unity-mac]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
clean: true
|
||||
|
||||
- name: Pull LFS files
|
||||
run: git lfs pull
|
||||
|
||||
- name: Ensure Unity version installed
|
||||
run: ./ci/github_actions/ensure_unity_installed.sh ios
|
||||
|
||||
- name: Restore Library/
|
||||
env:
|
||||
UNITY_CACHE_PLATFORM: ios
|
||||
run: ./ci/github_actions/restore_library.sh
|
||||
|
||||
- name: Build iOS Addressables
|
||||
run: ./ci/github_actions/build_ios_addressables.sh "$EAGLE0_BUILD_DIR/editor_ios_addressables.log"
|
||||
|
||||
- name: Persist Library/
|
||||
if: success()
|
||||
env:
|
||||
UNITY_CACHE_PLATFORM: ios
|
||||
run: ./ci/github_actions/persist_library.sh
|
||||
|
||||
- name: Upload Addressables to CDN
|
||||
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: ./ci/github_actions/upload_addressables.sh iOS
|
||||
|
||||
- name: Archive Build Log
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: editor_ios_addressables.log
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_ios_addressables.log
|
||||
retention-days: 5
|
||||
- name: Cleanup build directory
|
||||
if: always()
|
||||
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
|
||||
@@ -0,0 +1,213 @@
|
||||
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:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
clean: true
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Pull LFS files
|
||||
run: git lfs pull
|
||||
|
||||
- name: Ensure Unity version installed
|
||||
run: ./ci/github_actions/ensure_unity_installed.sh ios
|
||||
|
||||
- name: Restore Library/
|
||||
env:
|
||||
UNITY_CACHE_PLATFORM: ios
|
||||
run: ./ci/github_actions/restore_library.sh
|
||||
|
||||
- name: Build iOS Unity Project
|
||||
id: build
|
||||
run: |
|
||||
./ci/github_actions/build_unity_ios.sh "$EAGLE0_BUILD_DIR/eagle0iOS"
|
||||
echo "xcode_project_path=$EAGLE0_BUILD_DIR/eagle0iOS" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Persist Library/
|
||||
if: success()
|
||||
env:
|
||||
UNITY_CACHE_PLATFORM: ios
|
||||
run: ./ci/github_actions/persist_library.sh
|
||||
|
||||
- name: Upload Addressables to CDN
|
||||
if: success()
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: ./ci/github_actions/upload_addressables.sh iOS
|
||||
|
||||
- name: Zip Xcode project for artifact
|
||||
run: |
|
||||
cd $EAGLE0_BUILD_DIR
|
||||
# Use tar for speed - Xcode projects have many small files
|
||||
tar -czf eagle0iOS.tar.gz eagle0iOS
|
||||
|
||||
- name: Upload Xcode project
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: xcode-project-${{ github.run_id }}
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0iOS.tar.gz
|
||||
retention-days: 1
|
||||
|
||||
- name: Archive Build Log
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: editor_ios.log
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_ios.log
|
||||
retention-days: 5
|
||||
- name: Cleanup build directory
|
||||
if: always()
|
||||
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
|
||||
|
||||
archive-and-upload:
|
||||
needs: build-unity
|
||||
runs-on: [self-hosted, macOS, unity-mac]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: |
|
||||
ci
|
||||
scripts
|
||||
|
||||
- name: Clean download directory
|
||||
run: rm -rf $EAGLE0_BUILD_DIR/eagle0iOS
|
||||
|
||||
- name: Download Xcode project
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: xcode-project-${{ github.run_id }}
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}
|
||||
|
||||
- name: Extract Xcode project
|
||||
run: |
|
||||
cd $EAGLE0_BUILD_DIR
|
||||
tar -xzf eagle0iOS.tar.gz
|
||||
rm eagle0iOS.tar.gz
|
||||
ls -la eagle0iOS/
|
||||
|
||||
- name: Install Signing Certificate
|
||||
env:
|
||||
IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }}
|
||||
IOS_CERTIFICATE_PWD: ${{ secrets.IOS_CERTIFICATE_PWD }}
|
||||
run: |
|
||||
# Generate random keychain password
|
||||
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
|
||||
echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> $GITHUB_ENV
|
||||
|
||||
# Decode certificate
|
||||
echo "$IOS_CERTIFICATE" | base64 --decode > certificate.p12
|
||||
|
||||
# Delete any existing keychain from previous runs
|
||||
security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true
|
||||
|
||||
# Create temporary keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
|
||||
security default-keychain -s "$KEYCHAIN_NAME"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
|
||||
security set-keychain-settings -t 3600 -u "$KEYCHAIN_NAME"
|
||||
|
||||
# Import certificate
|
||||
security import certificate.p12 -k "$KEYCHAIN_NAME" -P "$IOS_CERTIFICATE_PWD" -T /usr/bin/codesign -T /usr/bin/security
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
|
||||
|
||||
# Add keychain to search list
|
||||
security list-keychains -d user -s "$KEYCHAIN_NAME" login.keychain
|
||||
|
||||
rm certificate.p12
|
||||
|
||||
- name: Install Provisioning Profile
|
||||
env:
|
||||
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
|
||||
run: |
|
||||
# Decode provisioning profile
|
||||
echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > profile.mobileprovision
|
||||
|
||||
# Extract UUID from provisioning profile
|
||||
PROFILE_UUID=$(/usr/libexec/PlistBuddy -c "Print :UUID" /dev/stdin <<< $(security cms -D -i profile.mobileprovision))
|
||||
echo "PROFILE_UUID=$PROFILE_UUID" >> $GITHUB_ENV
|
||||
|
||||
# Install to standard location
|
||||
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
|
||||
cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/$PROFILE_UUID.mobileprovision
|
||||
|
||||
rm profile.mobileprovision
|
||||
echo "Installed provisioning profile: $PROFILE_UUID"
|
||||
|
||||
- name: Archive and Export IPA
|
||||
env:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
chmod +x ./ci/github_actions/archive_ios.sh
|
||||
./ci/github_actions/archive_ios.sh "$EAGLE0_BUILD_DIR/eagle0iOS" "$EAGLE0_BUILD_DIR/archive" "$APPLE_TEAM_ID" "$PROFILE_UUID"
|
||||
|
||||
- name: Upload to TestFlight
|
||||
if: ${{ github.event.inputs.skip_upload != 'true' }}
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
|
||||
run: |
|
||||
chmod +x ./ci/github_actions/upload_testflight.sh
|
||||
./ci/github_actions/upload_testflight.sh "$EAGLE0_BUILD_DIR/archive/eagle0.ipa"
|
||||
|
||||
- name: Upload IPA artifact
|
||||
# Only keep artifact if we skipped TestFlight upload (for debugging)
|
||||
if: success() && github.event.inputs.skip_upload == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: eagle0-ios-${{ github.run_id }}
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/archive/eagle0.ipa
|
||||
retention-days: 1
|
||||
|
||||
- name: Cleanup Keychain
|
||||
if: always()
|
||||
run: |
|
||||
security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true
|
||||
- name: Cleanup build directory
|
||||
if: always()
|
||||
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
|
||||
|
||||
cleanup:
|
||||
needs: [build-unity, archive-and-upload]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Delete intermediate artifacts
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
artifact_name="xcode-project-${{ github.run_id }}"
|
||||
echo "Deleting artifact: $artifact_name"
|
||||
artifact_id=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" \
|
||||
-q ".artifacts[] | select(.name == \"$artifact_name\") | .id")
|
||||
if [ -n "$artifact_id" ]; then
|
||||
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id" || true
|
||||
fi
|
||||
@@ -21,6 +21,8 @@ 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:
|
||||
@@ -40,6 +42,8 @@ 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:
|
||||
@@ -53,6 +57,12 @@ 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]
|
||||
@@ -70,13 +80,16 @@ jobs:
|
||||
- name: Pull LFS files
|
||||
run: git lfs pull
|
||||
|
||||
- name: Ensure Unity version installed
|
||||
run: ./ci/github_actions/ensure_unity_installed.sh mac
|
||||
|
||||
- name: Restore Library/
|
||||
env:
|
||||
UNITY_CACHE_PLATFORM: mac
|
||||
run: ./ci/github_actions/restore_library.sh
|
||||
|
||||
- name: Build Mac Unity
|
||||
run: ./ci/github_actions/build_unity_mac.sh "/tmp/eagle0/eagle0MAC"
|
||||
run: ./ci/github_actions/build_unity_mac.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC"
|
||||
|
||||
- name: Persist Library/
|
||||
if: success()
|
||||
@@ -97,7 +110,7 @@ jobs:
|
||||
SPARKLE_EDDSA_PUBLIC_KEY: ${{ secrets.SPARKLE_EDDSA_PUBLIC_KEY }}
|
||||
run: |
|
||||
chmod +x ./scripts/inject_sparkle.sh
|
||||
./scripts/inject_sparkle.sh "/tmp/eagle0/eagle0MAC/eagle0.app"
|
||||
./scripts/inject_sparkle.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app"
|
||||
|
||||
- name: Check if should deploy
|
||||
id: check-deploy
|
||||
@@ -126,18 +139,31 @@ jobs:
|
||||
echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12
|
||||
|
||||
# Delete any existing keychain from previous runs
|
||||
security delete-keychain build.keychain 2>/dev/null || true
|
||||
security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true
|
||||
|
||||
# Create temporary keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security default-keychain -s build.keychain
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
|
||||
security default-keychain -s "$KEYCHAIN_NAME"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
|
||||
|
||||
# Import certificate
|
||||
security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign
|
||||
echo "=== Importing certificate ==="
|
||||
security import certificate.p12 -k "$KEYCHAIN_NAME" -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign -T /usr/bin/security
|
||||
|
||||
# Allow codesign to access keychain
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.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
|
||||
|
||||
# Clean up
|
||||
rm certificate.p12
|
||||
@@ -148,7 +174,7 @@ jobs:
|
||||
SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
|
||||
run: |
|
||||
chmod +x ./scripts/codesign_mac_app.sh
|
||||
./scripts/codesign_mac_app.sh "/tmp/eagle0/eagle0MAC/eagle0.app" "ci/mac/eagle0.entitlements"
|
||||
./scripts/codesign_mac_app.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app" "ci/mac/eagle0.entitlements"
|
||||
|
||||
- name: Submit for Notarization
|
||||
id: notarize-submit
|
||||
@@ -159,17 +185,17 @@ jobs:
|
||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
chmod +x ./scripts/notarize_submit.sh
|
||||
./scripts/notarize_submit.sh "/tmp/eagle0/eagle0MAC/eagle0.app" >> $GITHUB_OUTPUT
|
||||
./scripts/notarize_submit.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cleanup Keychain
|
||||
if: always()
|
||||
run: |
|
||||
security delete-keychain build.keychain 2>/dev/null || true
|
||||
security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true
|
||||
|
||||
- name: Zip signed app for artifact
|
||||
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
|
||||
run: |
|
||||
cd /tmp/eagle0/eagle0MAC
|
||||
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
|
||||
ditto -c -k --keepParent eagle0.app eagle0.app.zip
|
||||
|
||||
- name: Upload signed app
|
||||
@@ -177,7 +203,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signed-mac-app-${{ github.run_id }}
|
||||
path: /tmp/eagle0/eagle0MAC/eagle0.app.zip
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app.zip
|
||||
retention-days: 1
|
||||
|
||||
- name: Archive Build Log
|
||||
@@ -185,8 +211,11 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: editor_mac.log
|
||||
path: /tmp/eagle0/editor_mac.log
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_mac.log
|
||||
retention-days: 5
|
||||
- name: Cleanup build directory
|
||||
if: always()
|
||||
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
|
||||
|
||||
wait-notarization:
|
||||
needs: build-and-sign
|
||||
@@ -199,20 +228,20 @@ jobs:
|
||||
sparse-checkout: scripts
|
||||
|
||||
- name: Clean download directory
|
||||
run: rm -rf /tmp/eagle0/eagle0MAC
|
||||
run: rm -rf ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
|
||||
|
||||
- name: Download signed app
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: signed-mac-app-${{ github.run_id }}
|
||||
path: /tmp/eagle0/eagle0MAC
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
|
||||
|
||||
- name: Unzip signed app
|
||||
run: |
|
||||
cd /tmp/eagle0/eagle0MAC
|
||||
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
|
||||
ditto -x -k eagle0.app.zip .
|
||||
rm eagle0.app.zip
|
||||
ls -la /tmp/eagle0/eagle0MAC/eagle0.app/
|
||||
ls -la ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app/
|
||||
|
||||
- name: Wait for Notarization and Staple
|
||||
env:
|
||||
@@ -221,11 +250,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 }}" "/tmp/eagle0/eagle0MAC/eagle0.app"
|
||||
./scripts/notarize_wait.sh "${{ needs.build-and-sign.outputs.submission_id }}" "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app"
|
||||
|
||||
- name: Zip notarized app for artifact
|
||||
run: |
|
||||
cd /tmp/eagle0/eagle0MAC
|
||||
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
|
||||
rm -f eagle0.app.zip
|
||||
ditto -c -k --keepParent eagle0.app eagle0.app.zip
|
||||
|
||||
@@ -233,8 +262,11 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: notarized-mac-app-${{ github.run_id }}
|
||||
path: /tmp/eagle0/eagle0MAC/eagle0.app.zip
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/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]
|
||||
@@ -247,17 +279,17 @@ jobs:
|
||||
fetch-depth: 0 # For version numbering
|
||||
|
||||
- name: Clean download directory
|
||||
run: rm -rf /tmp/eagle0/eagle0MAC
|
||||
run: rm -rf ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
|
||||
|
||||
- name: Download notarized app
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: notarized-mac-app-${{ github.run_id }}
|
||||
path: /tmp/eagle0/eagle0MAC
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
|
||||
|
||||
- name: Unzip notarized app
|
||||
run: |
|
||||
cd /tmp/eagle0/eagle0MAC
|
||||
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
|
||||
ditto -x -k eagle0.app.zip .
|
||||
rm eagle0.app.zip
|
||||
|
||||
@@ -279,12 +311,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="/tmp/eagle0/eagle0MAC/eagle0.app"
|
||||
APP_PATH="${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app"
|
||||
BUILD_NUMBER=$(/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$APP_PATH/Contents/Info.plist")
|
||||
VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP_PATH/Contents/Info.plist")
|
||||
|
||||
bazel run //src/main/go/net/eagle0/build/mac_build_handler:mac_build_handler -- \
|
||||
"/tmp/eagle0/eagle0MAC/eagle0.app" \
|
||||
"${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app" \
|
||||
"$VERSION" \
|
||||
"$BUILD_NUMBER" \
|
||||
"$BACKGROUND_PATH" \
|
||||
@@ -307,6 +339,9 @@ 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:
|
||||
|
||||
@@ -18,6 +18,8 @@ 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:
|
||||
@@ -37,12 +39,18 @@ 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]
|
||||
@@ -54,12 +62,14 @@ jobs:
|
||||
clean: true # Remove untracked files from previous builds
|
||||
- name: Pull lfs files
|
||||
run: git lfs pull
|
||||
- name: Ensure Unity version installed
|
||||
run: ./ci/github_actions/ensure_unity_installed.sh windows
|
||||
- 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 "/tmp/eagle0/eagle0WIN"
|
||||
run: ./ci/github_actions/build_unity.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0WIN"
|
||||
- name: Persist Library/
|
||||
if: success()
|
||||
env:
|
||||
@@ -76,7 +86,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 -- "/tmp/eagle0/eagle0WIN" "/tmp/unity_manifest.txt"
|
||||
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"
|
||||
|
||||
- name: Update unified manifest
|
||||
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
@@ -106,5 +116,8 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: editor_win.log
|
||||
path: /tmp/eagle0/editor_win.log
|
||||
retention-days: 5
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_win.log
|
||||
retention-days: 5
|
||||
- name: Cleanup build directory
|
||||
if: always()
|
||||
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
|
||||
@@ -122,6 +122,7 @@ use_repo(
|
||||
"com_github_webview_webview_go",
|
||||
"org_golang_google_grpc",
|
||||
"org_golang_google_protobuf",
|
||||
"org_golang_x_sys",
|
||||
)
|
||||
|
||||
#
|
||||
|
||||
@@ -317,6 +317,13 @@ 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",
|
||||
@@ -328,6 +335,7 @@ oci_image(
|
||||
tars = [
|
||||
":busybox_layer",
|
||||
":auth_binary_layer",
|
||||
":auth_attributions_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
)
|
||||
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#!/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"
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Build iOS Addressables only (no player build)
|
||||
# This switches Unity to iOS target and builds addressables for CDN upload
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
# Read Unity version from project file
|
||||
UNITY_VERSION=$(grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //')
|
||||
|
||||
# Use runner-specific build directory if EAGLE0_BUILD_DIR is set, otherwise default
|
||||
BUILD_BASE="${EAGLE0_BUILD_DIR:-/tmp/eagle0}"
|
||||
|
||||
WORKSPACE=$(pwd)
|
||||
|
||||
echo "Building protos"
|
||||
./scripts/build_protos.sh
|
||||
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
|
||||
LOG_PATH=${1:-"${BUILD_BASE}/editor_ios_addressables.log"}
|
||||
|
||||
echo "Building iOS Addressables"
|
||||
|
||||
mkdir -p "$(dirname "$LOG_PATH")"
|
||||
|
||||
# Build Addressables for iOS target
|
||||
# Uses BuildiOSAddressables which explicitly switches build target
|
||||
${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
|
||||
-nographics \
|
||||
-batchmode \
|
||||
-quit \
|
||||
-executeMethod BuildScript.BuildiOSAddressables \
|
||||
-logFile "$LOG_PATH" \
|
||||
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
|
||||
|
||||
echo "iOS Addressables build complete"
|
||||
echo "Bundles should be in: $WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0/ServerData/iOS/"
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
. ./ci/unity_version.sh
|
||||
# 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: //')
|
||||
|
||||
WORKSPACE=$(pwd)
|
||||
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
LOG_PATH=""
|
||||
# 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)
|
||||
|
||||
@@ -12,7 +13,7 @@ COMMIT=$(/usr/bin/git rev-parse --short HEAD)
|
||||
git log -3
|
||||
|
||||
/bin/echo "build Windows"
|
||||
LOG_PATH="/tmp/eagle0/editor_win.log"
|
||||
LOG_PATH="${BUILD_BASE}/editor_win.log"
|
||||
BUILD_DIR=$1
|
||||
|
||||
./ci/github_actions/build_windows.sh $BUILD_DIR $LOG_PATH
|
||||
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/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
|
||||
${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"
|
||||
|
||||
echo "iOS Unity build complete"
|
||||
echo "Xcode project generated at: $BUILD_PATH"
|
||||
ls -la "$BUILD_PATH"
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
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"
|
||||
@@ -13,7 +16,7 @@ COMMIT=$(/usr/bin/git rev-parse --short HEAD)
|
||||
git log -3
|
||||
|
||||
/bin/echo "build Mac"
|
||||
LOG_PATH="/tmp/eagle0/editor_mac.log"
|
||||
LOG_PATH="${BUILD_BASE}/editor_mac.log"
|
||||
BUILD_DIR=$1
|
||||
|
||||
./ci/github_actions/build_mac.sh "$BUILD_DIR" "$LOG_PATH"
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
. ./ci/unity_version.sh
|
||||
# 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: //')
|
||||
|
||||
WORKSPACE=`pwd`
|
||||
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
|
||||
|
||||
Executable
+150
@@ -0,0 +1,150 @@
|
||||
#!/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 already installed
|
||||
if [ -d "${UNITY_INSTALL_PATH}/${UNITY_VERSION}" ]; then
|
||||
echo "✓ Unity ${UNITY_VERSION} is already installed at ${UNITY_INSTALL_PATH}/${UNITY_VERSION}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "✗ Unity ${UNITY_VERSION} not found at ${UNITY_INSTALL_PATH}/${UNITY_VERSION}"
|
||||
|
||||
# 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 [ -d "${UNITY_INSTALL_PATH}/${UNITY_VERSION}" ]; then
|
||||
echo "✓ Unity ${UNITY_VERSION} was installed while waiting for lock"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Attempting to install Unity ${UNITY_VERSION} via Unity Hub CLI..."
|
||||
echo ""
|
||||
|
||||
# Build the install command with platform-specific modules
|
||||
# Unity Hub CLI syntax: Unity\ Hub -- --headless install --version VERSION [--module MODULE]...
|
||||
INSTALL_CMD=("${UNITY_HUB_CLI}" -- --headless install --version "${UNITY_VERSION}")
|
||||
|
||||
case "${PLATFORM}" in
|
||||
mac)
|
||||
INSTALL_CMD+=(--module mac-il2cpp)
|
||||
;;
|
||||
windows)
|
||||
INSTALL_CMD+=(--module windows-mono)
|
||||
;;
|
||||
ios)
|
||||
INSTALL_CMD+=(--module ios)
|
||||
;;
|
||||
all)
|
||||
INSTALL_CMD+=(--module mac-il2cpp)
|
||||
INSTALL_CMD+=(--module windows-mono)
|
||||
INSTALL_CMD+=(--module ios)
|
||||
;;
|
||||
*)
|
||||
echo "Unknown platform: ${PLATFORM}"
|
||||
echo "Valid platforms: mac, windows, ios, all"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Running: ${INSTALL_CMD[*]}"
|
||||
echo ""
|
||||
|
||||
if "${INSTALL_CMD[@]}"; then
|
||||
echo ""
|
||||
echo "Unity Hub CLI command completed"
|
||||
else
|
||||
EXIT_CODE=$?
|
||||
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
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Upload IPA to TestFlight using Apple ID credentials (same as Mac notarization)
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
IPA_PATH=${1:?Usage: upload_testflight.sh <ipa_path>}
|
||||
|
||||
if [ ! -f "$IPA_PATH" ]; then
|
||||
echo "Error: IPA file not found: $IPA_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Uploading to TestFlight: $IPA_PATH"
|
||||
|
||||
# Uses same credentials as Mac notarization:
|
||||
# - APPLE_ID: Your Apple ID email
|
||||
# - APP_SPECIFIC_PASSWORD: App-specific password from appleid.apple.com
|
||||
|
||||
if [ -z "${APPLE_ID:-}" ]; then
|
||||
echo "Error: APPLE_ID environment variable not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${APP_SPECIFIC_PASSWORD:-}" ]; then
|
||||
echo "Error: APP_SPECIFIC_PASSWORD environment variable not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Uploading with xcrun altool..."
|
||||
|
||||
# Capture output to check for errors (altool may return 0 even on failure)
|
||||
OUTPUT=$(xcrun altool --upload-app \
|
||||
--type ios \
|
||||
--file "$IPA_PATH" \
|
||||
--username "$APPLE_ID" \
|
||||
--password "$APP_SPECIFIC_PASSWORD" 2>&1) || true
|
||||
|
||||
echo "$OUTPUT"
|
||||
|
||||
# Check for error indicators in output
|
||||
if echo "$OUTPUT" | grep -q "ERROR:"; then
|
||||
echo "ERROR: Upload failed. See error messages above."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Upload complete! Check App Store Connect for processing status."
|
||||
echo "The build should appear in TestFlight within 15-30 minutes after processing."
|
||||
@@ -1 +0,0 @@
|
||||
UNITY_VERSION='6000.3.0f1'
|
||||
@@ -1,471 +0,0 @@
|
||||
# Admin Server Enhancement Plan
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines enhancements to the Go admin server (`src/main/go/net/eagle0/admin_server/`) to provide a proper web UI for game administration.
|
||||
|
||||
### Current State
|
||||
|
||||
The admin server provides a full web UI with htmx interactivity:
|
||||
- `GET /` - Redirect to games list
|
||||
- `GET /games` - Game list page (HTML)
|
||||
- `GET /games/{id}` - Game detail with action history
|
||||
- `GET /games/{id}/history` - History rows (htmx partial, infinite scroll)
|
||||
- `GET /games/{id}/action/{index}` - Action detail (htmx partial)
|
||||
- `POST /games/{id}/rewind` - Rewind game to target action
|
||||
- `GET /settings` - Settings list with live search
|
||||
- `POST /settings/update` - Update setting value
|
||||
- `GET /health` - Health check (JSON)
|
||||
- `GET /api/games` - JSON API for programmatic access
|
||||
- `GET /api/games/{id}/history` - JSON API for history
|
||||
|
||||
### Goals
|
||||
|
||||
1. **Web UI**: Replace raw JSON with an interactive HTML interface
|
||||
2. **Settings Management**: View and modify the 275+ game settings at runtime
|
||||
3. **Game Rewind**: Restore a game to a previous action count
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Technology Choice: Go Templates + htmx
|
||||
|
||||
**Rationale:**
|
||||
- Single binary deployment (no separate frontend build)
|
||||
- htmx provides interactivity without JavaScript framework complexity
|
||||
- Familiar HTML/CSS, minimal learning curve
|
||||
- Excellent for admin tools where SEO and bundle size don't matter
|
||||
|
||||
**Alternatives Considered:**
|
||||
- React/Vue SPA: Adds build complexity, separate deployment artifact
|
||||
- Server-side only: Less interactive, full page reloads
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
src/main/go/net/eagle0/admin_server/
|
||||
├── admin_server.go # Main entry point, HTTP routes
|
||||
├── handlers/
|
||||
│ ├── games.go # Game list and detail handlers
|
||||
│ ├── settings.go # Settings list and update handlers
|
||||
│ └── rewind.go # Game rewind handlers
|
||||
├── templates/
|
||||
│ ├── layout.html # Base layout with nav, htmx includes
|
||||
│ ├── games/
|
||||
│ │ ├── list.html # Game list page
|
||||
│ │ ├── detail.html # Single game view with history
|
||||
│ │ └── history.html # Partial for history table (htmx)
|
||||
│ ├── settings/
|
||||
│ │ ├── list.html # Settings list with search/filter
|
||||
│ │ └── edit.html # Inline edit partial (htmx)
|
||||
│ └── rewind/
|
||||
│ └── confirm.html # Rewind confirmation modal
|
||||
├── static/
|
||||
│ ├── style.css # Minimal CSS (Pico CSS or similar)
|
||||
│ └── htmx.min.js # htmx library
|
||||
└── BUILD.bazel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: Web UI
|
||||
|
||||
### Routes
|
||||
|
||||
| Route | Method | Description |
|
||||
|-------|--------|-------------|
|
||||
| `/` | GET | Redirect to `/games` |
|
||||
| `/games` | GET | Game list page (HTML) |
|
||||
| `/games/{id}` | GET | Game detail page with history |
|
||||
| `/games/{id}/history` | GET | History partial (htmx, for infinite scroll) |
|
||||
| `/api/games` | GET | JSON API (existing, keep for programmatic access) |
|
||||
| `/api/games/{id}/history` | GET | JSON API (existing) |
|
||||
|
||||
### Game List Page
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Eagle Admin [Settings] [Health] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Running Games (3) │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Game abc123f Round 45 │ │
|
||||
│ │ Players: Liu Bei (Human), Cao Cao (AI), Sun Quan │ │
|
||||
│ │ Actions: 1,234 [View] [Rewind]│ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ Game def456a Round 12 │ │
|
||||
│ │ Players: Test Player (Human) │ │
|
||||
│ │ Actions: 456 [View] [Rewind]│ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Game Detail Page
|
||||
|
||||
Shows game info and scrollable action history:
|
||||
- **Reverse chronological order**: Most recent actions displayed first
|
||||
- Each action shows: index, type, round ID
|
||||
- **Clickable actions**: Clicking an action row expands to show JSON representation of the full action data
|
||||
- "Rewind to here" button on each action row
|
||||
- Infinite scroll loads more history via htmx (loading older actions as user scrolls down)
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
1. **Embed static files**: Use `//go:embed` to bundle templates and static files
|
||||
2. **Template functions**: Add helpers for formatting (hex IDs, timestamps, action summaries)
|
||||
3. **CSS framework**: Use Pico CSS (~10KB) for clean defaults without classes
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: Settings Management
|
||||
|
||||
### New gRPC Endpoints (Eagle Server)
|
||||
|
||||
Add to `eagle.proto`:
|
||||
|
||||
```protobuf
|
||||
message Setting {
|
||||
string name = 1;
|
||||
string type = 2; // "Int" or "Double"
|
||||
string value = 3; // Current value as string
|
||||
string default_value = 4; // Default from BUILD.bazel
|
||||
string description = 5; // Optional, for UI hints
|
||||
}
|
||||
|
||||
message GetSettingsRequest {
|
||||
string filter = 1; // Optional name filter (substring match)
|
||||
}
|
||||
|
||||
message GetSettingsResponse {
|
||||
repeated Setting settings = 1;
|
||||
}
|
||||
|
||||
message UpdateSettingRequest {
|
||||
string name = 1;
|
||||
string value = 2;
|
||||
}
|
||||
|
||||
message UpdateSettingResponse {
|
||||
Setting setting = 1; // Updated setting
|
||||
string error = 2; // Empty on success
|
||||
}
|
||||
|
||||
service Eagle {
|
||||
// ... existing methods ...
|
||||
rpc GetSettings(GetSettingsRequest) returns (GetSettingsResponse);
|
||||
rpc UpdateSetting(UpdateSettingRequest) returns (UpdateSettingResponse);
|
||||
}
|
||||
```
|
||||
|
||||
### Eagle Server Implementation
|
||||
|
||||
Create a settings registry that:
|
||||
1. Discovers all `IntSetting` and `DoubleSetting` instances via reflection or explicit registration
|
||||
2. Provides get/set by name
|
||||
3. Validates types on update
|
||||
|
||||
```scala
|
||||
// src/main/scala/net/eagle0/eagle/library/settings/SettingsRegistry.scala
|
||||
object SettingsRegistry {
|
||||
private val settings: Map[String, Either[IntSetting, DoubleSetting]] = Map(
|
||||
"ActionVigorCost" -> Left(ActionVigorCost),
|
||||
"BaseFoodBuyPrice" -> Right(BaseFoodBuyPrice),
|
||||
// ... register all 275 settings
|
||||
)
|
||||
|
||||
def getAll(filter: Option[String]): Seq[Setting] = ...
|
||||
def get(name: String): Option[Setting] = ...
|
||||
def update(name: String, value: String): Either[String, Setting] = ...
|
||||
}
|
||||
```
|
||||
|
||||
**Alternative: Code generation**
|
||||
|
||||
Rather than manually registering 275 settings, modify `setting_rule.bzl` to generate a registry file during build.
|
||||
|
||||
### Admin Server Routes
|
||||
|
||||
| Route | Method | Description |
|
||||
|-------|--------|-------------|
|
||||
| `/settings` | GET | Settings list page with search |
|
||||
| `/settings/{name}` | GET | Single setting detail (htmx partial) |
|
||||
| `/settings/{name}` | PUT | Update setting value |
|
||||
| `/api/settings` | GET | JSON API |
|
||||
| `/api/settings/{name}` | PUT | JSON API |
|
||||
|
||||
### Settings UI
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Eagle Admin [Games] [Health] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Settings [Search: __________ ] │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ ActionVigorCost (Int) │ │
|
||||
│ │ Current: [15 ] Default: 15 [Save] │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ BaseFoodBuyPrice (Double) │ │
|
||||
│ │ Current: [0.5 ] Default: 0.5 [Save] │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ... (275 settings, virtualized/paginated) ... │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Considerations
|
||||
|
||||
1. **Persistence**: Settings changes are in-memory only. Document that restarts reset to defaults.
|
||||
2. **Validation**: Validate numeric ranges where applicable (e.g., percentages 0-100)
|
||||
3. **Categories**: Consider grouping settings by prefix (AI*, Combat*, Economy*, etc.)
|
||||
4. **Audit log**: Log setting changes with timestamp for debugging
|
||||
|
||||
---
|
||||
|
||||
## Feature 3: Game Rewind
|
||||
|
||||
### Concept
|
||||
|
||||
Restore a game to a previous point in its action history. This is useful for:
|
||||
- Debugging issues that occurred at a specific point
|
||||
- Testing "what if" scenarios
|
||||
- Recovering from bugs that corrupted state
|
||||
|
||||
### New gRPC Endpoint
|
||||
|
||||
Add to `eagle.proto`:
|
||||
|
||||
```protobuf
|
||||
message RewindGameRequest {
|
||||
int64 game_id = 1;
|
||||
int32 target_action_count = 2; // Rewind to state after this many actions
|
||||
}
|
||||
|
||||
message RewindGameResponse {
|
||||
bool success = 1;
|
||||
string error = 2;
|
||||
int32 new_action_count = 3;
|
||||
int32 disconnected_clients = 4; // Number of clients that were disconnected
|
||||
}
|
||||
|
||||
service Eagle {
|
||||
// ... existing methods ...
|
||||
rpc RewindGame(RewindGameRequest) returns (RewindGameResponse);
|
||||
}
|
||||
```
|
||||
|
||||
### Eagle Server Implementation
|
||||
|
||||
The `GameHistory` already stores `ActionWithResultingState` for each action, which includes the `GameState` after that action. Rewinding means:
|
||||
|
||||
1. **Validate**: Check that `target_action_count` is within valid range (0 to current count)
|
||||
2. **Get target state**: Retrieve `GameState` at target action count from history
|
||||
3. **Disconnect clients**: Close all human player connections (they'll need to reconnect)
|
||||
4. **Replace engine**: Create new `EngineImpl` with target state and truncated history
|
||||
5. **Reset AI state**: Clear any cached AI state that depends on current game state
|
||||
|
||||
```scala
|
||||
// GameController.scala (pseudocode)
|
||||
def rewindTo(targetActionCount: Int): Either[String, RewindResult] = {
|
||||
if (targetActionCount < 0 || targetActionCount > engine.history.count)
|
||||
return Left(s"Invalid action count: $targetActionCount")
|
||||
|
||||
// Get state at target point
|
||||
val targetState = engine.history.stateAt(targetActionCount)
|
||||
val truncatedHistory = engine.history.truncateTo(targetActionCount)
|
||||
|
||||
// Disconnect all human clients
|
||||
val disconnectedCount = humanClients.length
|
||||
humanClients.foreach(_.disconnect("Game rewound by admin"))
|
||||
|
||||
// Create new engine at target state
|
||||
val newEngine = EngineImpl(
|
||||
gameId = engine.gameId,
|
||||
currentState = targetState,
|
||||
history = truncatedHistory,
|
||||
// ... other fields
|
||||
)
|
||||
|
||||
// Replace controller's engine
|
||||
this.engine = newEngine
|
||||
|
||||
Right(RewindResult(targetActionCount, disconnectedCount))
|
||||
}
|
||||
```
|
||||
|
||||
### GameHistory Enhancement
|
||||
|
||||
Add method to get state at a specific action count:
|
||||
|
||||
```scala
|
||||
trait GameHistory {
|
||||
// ... existing methods ...
|
||||
|
||||
def stateAt(actionCount: Int): GameState = {
|
||||
if (actionCount == 0) initialState
|
||||
else all(actionCount - 1).resultingState
|
||||
}
|
||||
|
||||
def truncateTo(actionCount: Int): GameHistory = {
|
||||
GameHistoryImpl(
|
||||
initialState = initialState,
|
||||
actions = all.take(actionCount)
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Admin Server Route
|
||||
|
||||
| Route | Method | Description |
|
||||
|-------|--------|-------------|
|
||||
| `/games/{id}/rewind` | POST | Rewind game (form: `target_action_count`) |
|
||||
| `/games/{id}/rewind/confirm` | GET | Confirmation modal (htmx partial) |
|
||||
|
||||
### Rewind UI Flow
|
||||
|
||||
1. User views game history
|
||||
2. User clicks "Rewind to here" on an action row
|
||||
3. Confirmation modal appears via htmx:
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Rewind Game abc123f? │
|
||||
│ │
|
||||
│ This will: │
|
||||
│ • Restore to action 456 (Round 23) │
|
||||
│ • Discard 778 subsequent actions │
|
||||
│ • Disconnect 2 connected players │
|
||||
│ │
|
||||
│ This cannot be undone. │
|
||||
│ │
|
||||
│ [Cancel] [Rewind] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
4. On confirm, POST to `/games/{id}/rewind`
|
||||
5. Success: redirect to game detail showing new state
|
||||
6. Error: show error message
|
||||
|
||||
### Safety Considerations
|
||||
|
||||
1. **No undo**: Rewinding discards history. Consider optional backup before rewind.
|
||||
2. **Client disconnect**: All connected clients are forcibly disconnected.
|
||||
3. **AI state**: Ensure AI clients restart cleanly after rewind.
|
||||
4. **Concurrent access**: Lock game during rewind to prevent race conditions.
|
||||
5. **Authorization**: In production, require admin authentication.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Web UI Foundation
|
||||
|
||||
**Status: Complete**
|
||||
|
||||
1. ✅ Set up Go templates with `embed`
|
||||
2. ✅ Add Pico CSS and htmx
|
||||
3. ✅ Create base layout with navigation
|
||||
4. ✅ Convert `/games` to HTML with styling
|
||||
5. ✅ Add game detail page with history table
|
||||
6. ✅ Implement htmx infinite scroll for history
|
||||
7. ✅ Reverse history order (most recent first)
|
||||
8. ✅ Clickable action rows that expand to show JSON representation
|
||||
9. ✅ Add `/games/{id}/action/{index}` endpoint for fetching action details
|
||||
|
||||
**Deliverable**: Browsable game list and history in HTML with clickable action details
|
||||
|
||||
### Phase 2: Settings Management
|
||||
|
||||
**Status: Complete**
|
||||
|
||||
1. ✅ Add `GetSettings` to `eagle.proto` (uses existing `AddSettings` for updates)
|
||||
2. ✅ Add `getAllSettings` method to auto-generated `SettingsLoader`
|
||||
3. ✅ Implement `getSettings` in `EagleServiceImpl`
|
||||
4. ✅ Create settings list page with live search
|
||||
5. ✅ Add inline editing with htmx
|
||||
6. ✅ Modified settings are highlighted
|
||||
|
||||
**Deliverable**: View and edit settings via admin UI
|
||||
|
||||
### Phase 3: Game Rewind
|
||||
|
||||
**Status: Complete**
|
||||
|
||||
1. ✅ Add `RewindGame` to `eagle.proto`
|
||||
2. ✅ Implement `stateAt` and `truncateTo` in `GameHistory`
|
||||
3. ✅ Implement rewind logic in `Engine` and `GameController`
|
||||
4. ✅ Add rewind confirmation (htmx `hx-confirm` dialog)
|
||||
5. ✅ Handle client disconnection gracefully
|
||||
6. ✅ Add rewind button to history rows
|
||||
7. ✅ Implement `rewindGame` in `GamesManager` and `EagleServiceImpl`
|
||||
8. ✅ Add admin server `/games/{id}/rewind` POST handler
|
||||
9. ✅ Add success/error feedback UI
|
||||
|
||||
**Deliverable**: Rewind games to any previous action
|
||||
|
||||
### Phase 4: Polish
|
||||
|
||||
**Status: Not Started**
|
||||
|
||||
#### High Priority
|
||||
1. **Add tests for rewind functionality**
|
||||
- `PersistedHistory.truncateTo` (handles complex persisted vs recent logic)
|
||||
- `InMemoryHistory.truncateTo`
|
||||
- `EngineImpl.rewindTo`
|
||||
- `GameController.rewindTo`
|
||||
- `GamesManager.rewindGame`
|
||||
|
||||
2. **Improve action history display**
|
||||
- Human-readable action type names (e.g., "New Round" instead of "NewRoundAction")
|
||||
- Show acting faction/province when available
|
||||
- Action summaries from the `summary` field in `GameHistoryEntry`
|
||||
|
||||
#### Medium Priority
|
||||
3. **Settings improvements**
|
||||
- Group settings by category prefix (AI*, Combat*, Economy*, etc.)
|
||||
- Show setting descriptions where available
|
||||
- Pagination for large settings lists
|
||||
|
||||
4. **Error handling improvements**
|
||||
- Better error messages on failed operations
|
||||
- Retry logic for transient gRPC failures
|
||||
|
||||
#### Low Priority (Nice to Have)
|
||||
5. **Basic auth** - HTTP Basic Auth or OAuth for production use
|
||||
6. **Audit logging** - Log admin actions with timestamps
|
||||
7. **Documentation** - Usage guide, deployment notes
|
||||
|
||||
#### Future Considerations
|
||||
- Game creation from admin UI
|
||||
- Player management (view connected players, force disconnect)
|
||||
- Export game history to file
|
||||
- Metrics/stats dashboard
|
||||
|
||||
---
|
||||
|
||||
## Security Notes
|
||||
|
||||
The admin server is intended for local/trusted network use only. For production:
|
||||
|
||||
1. **Do not expose to public internet** without authentication
|
||||
2. Consider adding HTTP Basic Auth or OAuth
|
||||
3. Run on internal network or behind VPN
|
||||
4. Log all admin actions for audit trail
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Settings persistence**: Should we add optional persistence to disk/database?
|
||||
2. **Game snapshots**: Should rewind create a backup first?
|
||||
3. **Multi-admin**: Need locking if multiple admins access simultaneously?
|
||||
4. **Shardok settings**: Are there Shardok (C++) settings to expose too?
|
||||
+107
-43
@@ -115,14 +115,35 @@ All 26 tracks have CC licenses with proper attribution:
|
||||
|-------|--------|---------|--------|
|
||||
| Market Day | RandomMind | Free without attribution | [Chosic](https://www.chosic.com/download-audio/27016/) |
|
||||
| Shopping List | Komiku | Free without attribution | [Chosic](https://www.chosic.com/download-audio/24714/) |
|
||||
| Medieval: Victory Theme | Unknown | **NEEDS VERIFICATION** | Unknown |
|
||||
| No Time for Greatness | Dima Koltsov | Audius Open Music License | [Audius](https://audius.co/dimathecomposer) |
|
||||
| Warriors of Demacia | Dima Koltsov | Audius Open Music License | [Audius](https://audius.co/dimathecomposer) |
|
||||
| Forest Queen Tale | Dima Koltsov | Audius Open Music License | [Audius](https://audius.co/dimathecomposer) |
|
||||
| Valor | Dima Koltsov | Audius Open Music License | [Audius](https://audius.co/dimathecomposer) |
|
||||
| Clouds | Dima Koltsov | Audius Open Music License | [Audius](https://audius.co/dimathecomposer) |
|
||||
| Medieval: Victory Theme | RandomMind | CC0 Public Domain | [Chosic](https://www.chosic.com/download-audio/28492/) |
|
||||
| No Time for Greatness | Dima Koltsov | CC BY 4.0 | [YouTube](https://www.youtube.com/watch?v=cQh0OWIFdgM) |
|
||||
| Warriors of Demacia | Dima Koltsov | CC BY 4.0 | [YouTube](https://www.youtube.com/watch?v=yktSUMJn9ao) |
|
||||
| Forest Queen Tale | Dima Koltsov | Presumed CC BY 4.0 | Not found on YouTube; other Dima Koltsov tracks are CC BY 4.0 |
|
||||
| Valor | Dima Koltsov | CC BY 4.0 | [YouTube](https://www.youtube.com/watch?v=uoHYJRPcS2Y) |
|
||||
| Clouds | Dima Koltsov | Presumed CC BY 4.0 | Not found on YouTube; other Dima Koltsov tracks are CC BY 4.0 |
|
||||
|
||||
**Note on Dima Koltsov tracks:** Audius uses an "Open Music License" by default, which is not the same as Creative Commons. These should eventually be replaced with CC-licensed alternatives.
|
||||
**Note on Dima Koltsov tracks:** 3 of 5 tracks confirmed CC BY 4.0 via YouTube. 2 remaining tracks (Forest Queen Tale, Clouds) presumed same license but not verified.
|
||||
|
||||
---
|
||||
|
||||
## 2b. Creative Commons Sound Effects
|
||||
|
||||
**Location:** `Assets/Shardok/Sounds/`
|
||||
|
||||
| File | Description | Artist | License | Source |
|
||||
|------|-------------|--------|---------|--------|
|
||||
| `rain_loop.ogg` | Rain falling on clay roof tiles (loopable) | aesqe | CC BY 4.0 | [Freesound #37618](https://freesound.org/people/aesqe/sounds/37618/) |
|
||||
| `blizzard_wind_loop.wav` | Wind draft loop (indoor recording, loops seamlessly) | nsstudios | CC BY 4.0 | [Freesound #651540](https://freesound.org/people/nsstudios/sounds/651540/) |
|
||||
| `thunder_distant.mp3` | Distant thunder rumble | LittleRainySeasons | CC BY 4.0 | [Freesound #351526](https://freesound.org/people/LittleRainySeasons/sounds/351526/) |
|
||||
| `thunder_loud.mp3` | Loud thunder clap | mokasza | CC BY 4.0 | [Freesound #810746](https://freesound.org/people/mokasza/sounds/810746/) |
|
||||
| `thunder_crack.wav` | Thunder crack | OneSoundToRuleThemAll | CC BY 4.0 | [Freesound #238796](https://freesound.org/people/OneSoundToRuleThemAll/sounds/238796/) |
|
||||
| `thunder_clap.wav` | Thunder clap | FreqMan | CC BY 4.0 | [Freesound #32544](https://freesound.org/people/FreqMan/sounds/32544/) |
|
||||
|
||||
**Location:** `Assets/Shardok/soundEffects/`
|
||||
|
||||
| File | Description | Artist | License | Source |
|
||||
|------|-------------|--------|---------|--------|
|
||||
| `runaway.mp3` | Medieval army running loop (gravel + metal/chain) | Yap_Audio_Production | CC BY 4.0 | [Freesound #218997](https://freesound.org/people/Yap_Audio_Production/sounds/218997/) |
|
||||
|
||||
---
|
||||
|
||||
@@ -138,32 +159,61 @@ All 26 tracks have CC licenses with proper attribution:
|
||||
|
||||
## 4. Potentially Problematic Assets (Review Needed)
|
||||
|
||||
### Clip Art (Unknown License)
|
||||
| File | Concern |
|
||||
|------|---------|
|
||||
| `Assets/Shardok/commandImages/bridge.png` | Clip art style wooden bridge, unknown source - **needs replacement** |
|
||||
| `Assets/Images/startFire.png` | Icon, unknown source - **needs verification or replacement** |
|
||||
### ~~Clip Art (Unknown License)~~ RESOLVED
|
||||
| File | Status |
|
||||
|------|--------|
|
||||
| ~~`Assets/Shardok/commandImages/bridge.png`~~ | **REPLACED** (2026-01-23) with AI-generated wooden rope bridge icon (ChatGPT/DALL-E 3, 512x512 PNG). No licensing restrictions - AI-generated for this project. |
|
||||
| ~~`Assets/Images/startFire.png`~~ | **REPLACED** (2026-01-23) with "Flame Icon" from [UXWing](https://uxwing.com/flame-icon/) (free for commercial use, no attribution required). Consolidated duplicate removed. |
|
||||
| ~~`Assets/Shardok/commandImages/startFire.png`~~ | **DELETED** (2026-01-23) - duplicate removed, all references updated to use `Assets/Images/startFire.png` |
|
||||
|
||||
### Shardok Sound Effects
|
||||
- **Location:** `Assets/Shardok/soundEffects/`
|
||||
- **Count:** 56 MP3 files
|
||||
- **Count:** 37 audio files (was incorrectly counted as 56 including .meta files)
|
||||
- **Contents:** Spell effects, movement, combat sounds
|
||||
- **Status:** Unknown origin - may be custom or need verification
|
||||
|
||||
### Free Icons
|
||||
- **Location:** `Assets/free_icons/`
|
||||
- **Count:** 8 PNG weather icons
|
||||
- **Status:** Verify "free" means commercially usable
|
||||
**Verified from [Zombie Monster - Undead Collection](https://assetstore.unity.com/packages/audio/sound-fx/creatures/zombie-monster-undead-collection-70662) (Unity Asset Store):**
|
||||
- `raise_undead.mp3`
|
||||
- `undead_break_control.mp3`
|
||||
- `undead_grew.wav`
|
||||
|
||||
### Terrain Hexes
|
||||
**⚠️ MUST REPLACE (1 remaining):**
|
||||
- ~~`anybody.mp3`~~ - **REPLACED** (2026-01-23) with `Positive Effect 6.wav` from Magic Spells Sound Effects LITE
|
||||
- ~~`burnination.mp3`~~ - **REPLACED** (2026-01-23) with `Magic Element Fire 04.wav` from Medieval Combat Sounds
|
||||
- `failure_horn.mp3` - licensing issue, no replacement found in purchased assets
|
||||
- ~~`runaway.mp3`~~ - **REPLACED** (2026-01-27) with `MedievalArmyRunningLoop.mp3` from Freesound (CC BY 4.0)
|
||||
|
||||
**Presumed from Unity Asset Store purchases (31 files):**
|
||||
Owner believes these are from: Fantasy Interface Sounds, Medieval Combat Sounds, Magic Spells Sound Effects LITE, and/or Medieval Battle Sound Pack.
|
||||
- `archery.mp3`, `battle_shout.mp3`, `boo.mp3`, `braved_water.mp3`
|
||||
- `build_bridge.mp3`, `build_bridge_failure.mp3`, `charge.mp3`
|
||||
- `dismiss_unit.mp3`, `duel_challenged.mp3`, `failure_horn.mp3`, `fear.mp3`, `fear_failed.mp3`
|
||||
- `fire_extinguish.mp3`, `fire_spread.mp3`, `fire_start.mp3`, `fire_start_failure.mp3`
|
||||
- `freeze.mp3`, `holy_wave.mp3`, `holy_wave_damage.mp3`, `jail_door.mp3`, `lightning.mp3`
|
||||
- `melee.mp3`, `meteor.mp3`, `mind_control.mp3`, `move.mp3`, `move 1.mp3`
|
||||
- `raging_fire.mp3`, `reduce.mp3`, `repair.mp3`, `repair_failed.mp3`, `splash.mp3`
|
||||
|
||||
### ~~Free Icons~~ RESOLVED
|
||||
- **Location:** `Assets/free_icons/` - **DELETED** (2026-01-27)
|
||||
- **Resolution:** All icons replaced with equivalents from purchased Asset Store packs:
|
||||
- `blizzard.png` → `16_blizzard_nobg.png` (4000_Fantasy_Icons)
|
||||
- `rain.png` → `12_Magic_rain_nobg.png` (4000_Fantasy_Icons)
|
||||
- `thunderstorm.png` → `27_Storm_nobg.png` (4000_Fantasy_Icons)
|
||||
- `wind.png` → `23_Light_blow_nobg.png` (4000_Fantasy_Icons)
|
||||
- `thermometer.png` → `startFire.png` (existing licensed asset)
|
||||
- `snow.png` → `16_blizzard_nobg.png` (4000_Fantasy_Icons)
|
||||
- `cloud.png`, `sun.png` → deleted (unused)
|
||||
|
||||
### ~~Terrain Hexes~~ VERIFIED
|
||||
- **Location:** `Assets/Terrain Hexes/`
|
||||
- **Count:** 85 PNG files
|
||||
- **Status:** Unknown source - verify licensing
|
||||
- **Status:** ✓ Confirmed Unity Asset Store purchase (2026-01-23)
|
||||
|
||||
### StrategyGameIcons
|
||||
### ~~StrategyGameIcons~~ VERIFIED
|
||||
- **Location:** `Assets/StrategyGameIcons/`
|
||||
- **Count:** 138 PNG files
|
||||
- **Status:** Unknown source - verify licensing
|
||||
- **Publisher:** REXARD
|
||||
- **Asset Store Link:** https://assetstore.unity.com/packages/2d/gui/icons/strategy-game-icons-64816
|
||||
- **Status:** ✓ Confirmed Unity Asset Store purchase (2026-01-23)
|
||||
|
||||
---
|
||||
|
||||
@@ -193,28 +243,31 @@ NuGet packages in `Assets/Packages/` all include LICENSE.TXT files:
|
||||
|
||||
## Action Items
|
||||
|
||||
### Must Verify Before Opening Public Access:
|
||||
### Must Replace Before Opening Public Access:
|
||||
|
||||
1. **Clip art images** - Unknown license, need replacement with properly licensed alternatives:
|
||||
- `Assets/Shardok/commandImages/bridge.png` - wooden bridge icon
|
||||
- `Assets/Images/startFire.png` - fire icon
|
||||
1. ~~**Clip art images**~~ - **RESOLVED** (2026-01-23): Replaced with properly licensed alternatives
|
||||
|
||||
2. **Shardok sound effects** - 56 MP3 files of unknown origin. Either:
|
||||
- Document their source
|
||||
- Replace with known-licensed alternatives
|
||||
- Confirm they were custom-created
|
||||
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
|
||||
|
||||
3. **Terrain Hexes** - 85 hex tiles of unknown source
|
||||
- **TODO:** Confirm if this is a Unity Asset Store purchase (owner believes it is)
|
||||
### Low Priority (Verify):
|
||||
|
||||
4. **StrategyGameIcons** - 138 icons of unknown source
|
||||
- **TODO:** Investigate origin - check Unity Asset Store purchase history
|
||||
3. **Dima Koltsov tracks** - 2 of 5 not verified: `Forest Queen Tale`, `Clouds` (presumed CC BY 4.0 like his other tracks)
|
||||
|
||||
5. **Medieval: Victory Theme** - Cannot find source or license. Either verify origin or replace.
|
||||
### Already Resolved:
|
||||
|
||||
6. **Dima Koltsov (Audius) tracks** - 5 tracks under Audius "Open Music License" (not Creative Commons). Replace with CC-licensed alternatives eventually.
|
||||
4. ~~**Free Icons**~~ - **RESOLVED** (2026-01-27): All replaced with Asset Store equivalents, folder deleted
|
||||
|
||||
7. **Discord logo** (`Eagle/Discord-Logo-Blurple.png`) - Likely fine for "Login with Discord" button per Discord brand guidelines, but verify usage complies with their terms
|
||||
5. ~~**Terrain Hexes**~~ - **VERIFIED** (2026-01-23): Confirmed Unity Asset Store purchase
|
||||
|
||||
6. ~~**StrategyGameIcons**~~ - **VERIFIED** (2026-01-23): Unity Asset Store purchase (REXARD)
|
||||
|
||||
7. ~~**Medieval: Victory Theme**~~ - **VERIFIED** (2026-01-23): CC0 Public Domain by RandomMind ([Chosic](https://www.chosic.com/download-audio/28492/))
|
||||
|
||||
8. ~~**Discord logo**~~ - **OK** (2026-01-23): Usage complies with Discord brand guidelines for "Login with Discord" button
|
||||
|
||||
### Already Safe:
|
||||
|
||||
@@ -228,12 +281,23 @@ NuGet packages in `Assets/Packages/` all include LICENSE.TXT files:
|
||||
|
||||
## Recommendation
|
||||
|
||||
Before public release:
|
||||
**Remaining before public release:**
|
||||
|
||||
1. Replace clip art images (`bridge.png`, `startFire.png`) with properly licensed alternatives (e.g., from [game-icons.net](https://game-icons.net) CC BY 3.0)
|
||||
2. Verify source of `Assets/Shardok/soundEffects/` MP3s
|
||||
3. Verify source of `Assets/Terrain Hexes/` and `Assets/StrategyGameIcons/`
|
||||
4. Replace Dima Koltsov Audius tracks with CC-licensed alternatives
|
||||
5. Find source of Medieval: Victory Theme or replace
|
||||
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
|
||||
|
||||
The bulk of your assets (95%+) are properly licensed Asset Store purchases or CC content.
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# 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
|
||||
@@ -0,0 +1,150 @@
|
||||
# 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
|
||||
@@ -1,383 +0,0 @@
|
||||
# Plan: Extract OAuth to Go Service
|
||||
|
||||
## Goal
|
||||
Move OAuth authentication handling from the Eagle Scala server into a separate Go service. This simplifies Eagle (gRPC-only, no HTTP), reduces complexity, and sets up for potentially moving JWT validation outside Eagle too.
|
||||
|
||||
## Architecture Decision: Sidecar Service (Not DO Functions)
|
||||
|
||||
**Recommendation: Go sidecar service on the same droplet, in a separate container**
|
||||
|
||||
**Why not DO Functions:**
|
||||
- OAuth requires **stateful sessions** (pendingOAuth/completedOAuth maps with 10-min TTL)
|
||||
- Client polling pattern (every 2 seconds) would incur high function invocation costs
|
||||
- Cold start latency problematic for auth flows
|
||||
- State would require external store (Redis), adding complexity
|
||||
|
||||
**Why sidecar (separate container):**
|
||||
- Simple process on same droplet, minimal network latency
|
||||
- In-memory state management (like current Scala impl)
|
||||
- Easy to monitor/debug alongside Eagle
|
||||
- Can share filesystem for key files (RSA keys) via volume mounts
|
||||
- **Independent deployment**: Deploying Eagle doesn't restart auth service (and vice versa)
|
||||
- **Independent scaling**: Could move to separate droplet later if needed
|
||||
|
||||
## Current Architecture (What Exists)
|
||||
|
||||
```
|
||||
Unity Client
|
||||
├── GetOAuthUrl RPC → Eagle AuthServiceImpl → OAuthService.getAuthUrl()
|
||||
├── [User browser auth] → HTTP callback → OAuthHttpHandler → OAuthService.handleCallback()
|
||||
├── CheckOAuthStatus RPC (polling) → AuthServiceImpl → OAuthService.checkStatus()
|
||||
└── All other RPCs include JWT → AuthorizationInterceptor validates
|
||||
```
|
||||
|
||||
**Key files:**
|
||||
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth flow, state management
|
||||
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - JWT creation/validation
|
||||
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD (persisted)
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC Auth service
|
||||
- `src/main/scala/net/eagle0/eagle/service/OAuthHttpHandler.scala` - HTTP callback handler
|
||||
|
||||
## Target Architecture (Phase 1)
|
||||
|
||||
```
|
||||
Unity Client
|
||||
├── GetOAuthUrl RPC ──────────────┐
|
||||
├── CheckOAuthStatus RPC (polling)├──→ Eagle (port 40032) ──proxy──→ Go Auth Container (port 40033)
|
||||
├── RefreshToken RPC ─────────────┘ │
|
||||
├── [User browser] → HTTP callback ────────────────────────────────────────┤
|
||||
│ ↓
|
||||
│ (Internal gRPC: GetOrCreateUser, GetUser)
|
||||
│ ↓
|
||||
└── Game RPCs with JWT ─────────────────────→ Eagle (port 40032) ← JWT validation stays here
|
||||
|
||||
[Same Droplet]
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌──────────────────────┐ ┌────────────────────────────────────┐ │
|
||||
│ │ Go Auth Container │◄────────►│ Eagle Container │ │
|
||||
│ │ (eagle0-auth) │ internal │ (eagle0-server) │ │
|
||||
│ │ │ gRPC │ │ │
|
||||
│ │ - OAuth flow │ │ - JWT validation │ │
|
||||
│ │ - JWT creation │ │ - UserService (persistence) │ │
|
||||
│ │ - HTTP callback │ │ - Game logic │ │
|
||||
│ └──────────────────────┘ └────────────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ └────────────────┬───────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ /etc/eagle0/keys/ (shared volume) │
|
||||
│ - private.pem │
|
||||
│ - public.pem │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Component Responsibilities
|
||||
|
||||
### Go Auth Service (NEW - separate container)
|
||||
- **OAuth flow**: getAuthUrl, handleCallback (HTTP), checkStatus
|
||||
- **State management**: pendingOAuth, completedOAuth maps with TTL
|
||||
- **JWT creation**: Issue access/refresh tokens (shares RSA private key with Eagle)
|
||||
- **Token refresh**: Validate refresh token, issue new access token
|
||||
- Calls Eagle's internal UserService gRPC to find/create users
|
||||
|
||||
### Eagle Server (SIMPLIFIED)
|
||||
- **JWT validation**: AuthorizationInterceptor stays (validates tokens on game RPCs)
|
||||
- **UserService**: Stays in Eagle (user persistence, display name logic)
|
||||
- **New internal gRPC**: Expose GetOrCreateUser, GetUser for Go service to call
|
||||
- **Proxy (Phase 1)**: Forward OAuth RPCs to Go service
|
||||
- **Remove (Phase 2)**: OAuthService, OAuthHttpHandler, HTTP server setup
|
||||
|
||||
### Unity Client (NO CHANGES in Phase 1)
|
||||
- Eagle proxies Auth RPCs to Go service
|
||||
- Client still connects to Eagle on port 40032
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Go Auth Service with Eagle Proxy (Zero Client Changes)
|
||||
|
||||
1. **Create Go service structure**
|
||||
```
|
||||
src/main/go/net/eagle0/authservice/
|
||||
├── main.go # Entry point, starts gRPC + HTTP servers
|
||||
├── oauth.go # OAuth state management, provider configs
|
||||
├── jwt.go # JWT creation (copy logic from Scala)
|
||||
├── handlers.go # gRPC handlers for Auth service
|
||||
├── http_callback.go # HTTP handler for OAuth callback
|
||||
└── BUILD.bazel
|
||||
```
|
||||
|
||||
2. **Internal gRPC proto for Eagle UserService**
|
||||
```protobuf
|
||||
// src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto
|
||||
|
||||
service InternalUserService {
|
||||
rpc GetOrCreateUser(GetOrCreateUserRequest) returns (GetOrCreateUserResponse);
|
||||
rpc GetUser(GetUserRequest) returns (GetUserResponse);
|
||||
}
|
||||
|
||||
message GetOrCreateUserRequest {
|
||||
string provider = 1; // "discord" or "google"
|
||||
string provider_user_id = 2;
|
||||
string email = 3;
|
||||
string avatar_url = 4;
|
||||
}
|
||||
|
||||
message GetOrCreateUserResponse {
|
||||
string user_id = 1;
|
||||
string display_name = 2;
|
||||
string avatar_url = 3;
|
||||
bool is_admin = 4;
|
||||
bool is_new_user = 5;
|
||||
}
|
||||
```
|
||||
|
||||
3. **Eagle: Expose InternalUserService**
|
||||
- New `InternalUserServiceImpl.scala` wrapping UserService
|
||||
- Bind to same port, different service name (internal only)
|
||||
|
||||
4. **Eagle: Proxy Auth RPCs to Go**
|
||||
- AuthServiceImpl delegates GetOAuthUrl, CheckOAuthStatus, RefreshToken to Go service
|
||||
- SetDisplayName, GetCurrentUser, Logout stay in Eagle
|
||||
|
||||
5. **Share RSA keys via volume mount**
|
||||
- Go service reads same key files as Eagle
|
||||
- Both can create valid JWTs
|
||||
- Eagle continues to validate JWTs
|
||||
|
||||
6. **Docker/Container setup**
|
||||
- New Dockerfile for Go auth service
|
||||
- docker-compose or Kubernetes config for both containers
|
||||
- Shared volume for /etc/eagle0/keys/
|
||||
- Internal network for container-to-container gRPC
|
||||
|
||||
### Phase 2: Client Direct to Go Service (Future)
|
||||
|
||||
1. **Update Unity client**
|
||||
- Connect to Go Auth service directly for OAuth RPCs
|
||||
- Keep connecting to Eagle for game RPCs
|
||||
|
||||
2. **Remove Eagle proxy code**
|
||||
- Delete AuthServiceImpl OAuth delegation
|
||||
- AuthServiceImpl only handles SetDisplayName, GetCurrentUser, Logout
|
||||
|
||||
### Phase 3: Move JWT Validation to Go (Optional Future)
|
||||
|
||||
1. **Go service validates JWTs**
|
||||
- Add ValidateToken RPC or use shared middleware pattern
|
||||
|
||||
2. **Eagle calls Go for validation**
|
||||
- AuthorizationInterceptor calls Go to validate tokens
|
||||
- OR: Use stateless validation (both share public key)
|
||||
|
||||
## Files to Create
|
||||
|
||||
### Go Service
|
||||
- `src/main/go/net/eagle0/authservice/main.go`
|
||||
- `src/main/go/net/eagle0/authservice/oauth.go`
|
||||
- `src/main/go/net/eagle0/authservice/jwt.go`
|
||||
- `src/main/go/net/eagle0/authservice/handlers.go`
|
||||
- `src/main/go/net/eagle0/authservice/http_callback.go`
|
||||
- `src/main/go/net/eagle0/authservice/BUILD.bazel`
|
||||
|
||||
### Protos
|
||||
- `src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto`
|
||||
|
||||
### Scala
|
||||
- `src/main/scala/net/eagle0/eagle/service/InternalUserServiceImpl.scala`
|
||||
|
||||
### Docker/Deployment
|
||||
- `ci/auth_service.Dockerfile`
|
||||
- Update `docker-compose.yml` (or equivalent)
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### Scala (Phase 1)
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - Proxy OAuth RPCs to Go
|
||||
- `src/main/scala/net/eagle0/eagle/Main.scala` - Start internal user service, add auth-service-url flag
|
||||
|
||||
### Scala (Phase 2 - Removal)
|
||||
- Delete `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala`
|
||||
- Delete `src/main/scala/net/eagle0/eagle/service/OAuthHttpHandler.scala`
|
||||
- Simplify `src/main/scala/net/eagle0/eagle/Main.scala` - Remove HTTP server
|
||||
|
||||
### Unity (Phase 2)
|
||||
- `Assets/Auth/OAuthManager.cs` - Point OAuth RPCs to Go service port
|
||||
- `Assets/EagleConnection.cs` - Add second channel for auth service
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
### State Management in Go
|
||||
```go
|
||||
type OAuthState struct {
|
||||
Provider string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type OAuthResult struct {
|
||||
Success bool
|
||||
UserInfo *ProviderUserInfo
|
||||
Provider string
|
||||
Error string
|
||||
}
|
||||
|
||||
var pendingOAuth = sync.Map{} // state -> OAuthState
|
||||
var completedOAuth = sync.Map{} // state -> OAuthResult
|
||||
|
||||
const stateExpiration = 10 * time.Minute
|
||||
|
||||
// Background goroutine cleans expired states every minute
|
||||
func cleanupExpiredStates() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
for range ticker.C {
|
||||
cutoff := time.Now().Add(-stateExpiration)
|
||||
pendingOAuth.Range(func(key, value any) bool {
|
||||
if value.(OAuthState).CreatedAt.Before(cutoff) {
|
||||
pendingOAuth.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
// Similar for completedOAuth
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### JWT Creation in Go
|
||||
```go
|
||||
import "github.com/golang-jwt/jwt/v5"
|
||||
|
||||
type EagleClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
UserId string `json:"userId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
}
|
||||
|
||||
func CreateAccessToken(userId, displayName string, isAdmin bool) (string, error) {
|
||||
claims := EagleClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
UserId: userId,
|
||||
DisplayName: displayName,
|
||||
IsAdmin: isAdmin,
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
return token.SignedString(privateKey)
|
||||
}
|
||||
```
|
||||
|
||||
### OAuth Provider Configs
|
||||
- Read from environment variables (same as current OAuthConfig.scala)
|
||||
- DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET
|
||||
- GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
|
||||
- OAUTH_CALLBACK_URL (e.g., https://eagle0.shardok.games/oauth/callback)
|
||||
|
||||
### Container Networking
|
||||
```yaml
|
||||
# docker-compose.yml example
|
||||
services:
|
||||
eagle0-auth:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ci/auth_service.Dockerfile
|
||||
ports:
|
||||
- "40033:40033" # gRPC
|
||||
- "8080:8080" # HTTP callback
|
||||
volumes:
|
||||
- ./keys:/etc/eagle0/keys:ro
|
||||
environment:
|
||||
- DISCORD_CLIENT_ID
|
||||
- DISCORD_CLIENT_SECRET
|
||||
- GOOGLE_CLIENT_ID
|
||||
- GOOGLE_CLIENT_SECRET
|
||||
- EAGLE_INTERNAL_URL=eagle0-server:40034
|
||||
|
||||
eagle0-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ci/eagle_run.Dockerfile
|
||||
ports:
|
||||
- "40032:40032" # Public gRPC
|
||||
expose:
|
||||
- "40034" # Internal gRPC (container-to-container only)
|
||||
volumes:
|
||||
- ./keys:/etc/eagle0/keys:ro
|
||||
- ./data:/var/lib/eagle0
|
||||
environment:
|
||||
- AUTH_SERVICE_URL=eagle0-auth:40033
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Development
|
||||
```bash
|
||||
# Terminal 1: Go Auth Service
|
||||
bazel run //src/main/go/net/eagle0/authservice:authservice -- \
|
||||
--grpc-port=40033 \
|
||||
--http-port=8080 \
|
||||
--eagle-internal-url=localhost:40034
|
||||
|
||||
# Terminal 2: Eagle Server
|
||||
bazel run //src/main/scala/net/eagle0/eagle:eagle_server -- \
|
||||
--eagle-grpc-port=40032 \
|
||||
--internal-grpc-port=40034 \
|
||||
--auth-service-url=localhost:40033
|
||||
```
|
||||
|
||||
### Production
|
||||
- Both containers on same droplet via docker-compose
|
||||
- Shared volume for RSA keys at /etc/eagle0/keys/
|
||||
- Internal Docker network for container-to-container communication
|
||||
- External access: 40032 (Eagle gRPC), 8080 (OAuth HTTP callback)
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit tests for Go service**
|
||||
- OAuth state management (expiration, cleanup)
|
||||
- JWT creation matches Scala output (test with same keys)
|
||||
- HTTP callback parsing
|
||||
|
||||
2. **Integration tests**
|
||||
- Go service ↔ Eagle internal gRPC
|
||||
- Full OAuth flow with mock provider
|
||||
|
||||
3. **Existing tests continue to pass**
|
||||
- All Scala tests (JWT validation, user service)
|
||||
|
||||
4. **End-to-end test**
|
||||
- Spin up both containers
|
||||
- Run OAuth flow through proxy
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Key file permissions | Shared volume with read-only mount |
|
||||
| State loss on Go restart | Document this (same as current Scala behavior); consider Redis later |
|
||||
| Clock skew affecting JWT | Both on same machine |
|
||||
| OAuth callback race | HTTP callback completes before gRPC poll |
|
||||
| Container networking | Use docker-compose for reliable internal DNS |
|
||||
| Proxy adds latency | Minimal (same machine), remove in Phase 2 |
|
||||
|
||||
## Estimated Scope
|
||||
|
||||
- **Phase 1**: ~500-700 lines Go, ~100 lines Scala changes, ~50 lines Docker config
|
||||
- **Phase 2**: ~50 lines Unity, deletion of ~300 lines Scala
|
||||
- **Phase 3**: Optional, separate decision
|
||||
|
||||
## Alternative Considered: Move Everything to Go
|
||||
|
||||
Could move UserService to Go as well, but:
|
||||
- UserService is tightly integrated with game persistence
|
||||
- Would require duplicating persistence layer
|
||||
- Not worth the complexity for now
|
||||
|
||||
Keep UserService in Eagle, expose via internal gRPC.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **HTTP callback routing**: Does the OAuth callback URL need to change, or can we route traffic from the existing URL to the new Go service?
|
||||
2. **Health checks**: Should we add health check endpoints for container orchestration?
|
||||
3. **Logging**: Should Go service log to same format/destination as Eagle?
|
||||
@@ -1,189 +0,0 @@
|
||||
# Discord + Google OAuth Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Replace HTTP Basic Auth with OAuth 2.0 (Discord + Google) for Eagle0. Users authenticate via system browser, receive JWT tokens, and choose their own display names.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Unity Client Eagle Server
|
||||
| |
|
||||
| 1. Click "Login with Discord/Google" |
|
||||
| -------------------------------------------------> |
|
||||
| GetOAuthUrl(provider) -> auth_url + state |
|
||||
| |
|
||||
| 2. Open system browser -> OAuth consent |
|
||||
| 3. User authenticates with provider |
|
||||
| 4. Redirect to eagle0://auth/callback?code=xxx |
|
||||
| |
|
||||
| 5. ExchangeCode(code, state) |
|
||||
| -------------------------------------------------> |
|
||||
| Exchange code with provider |
|
||||
| Fetch user info (id, email, avatar) |
|
||||
| Create/update user record |
|
||||
| Issue JWT + refresh token |
|
||||
| <------------------------------------------------- |
|
||||
| (jwt, refresh_token, user_info, is_new_user) |
|
||||
| |
|
||||
| 6. [If new user] SetDisplayName(name) |
|
||||
| -------------------------------------------------> |
|
||||
| |
|
||||
| 7. Subsequent gRPC calls |
|
||||
| Authorization: Bearer <jwt> |
|
||||
| -------------------------------------------------> |
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| OAuth flow | System browser + deep link | Secure, supports password managers |
|
||||
| Code exchange | Eagle server directly | No separate auth service needed |
|
||||
| JWT signing | RS256 (asymmetric) | Future flexibility for token verification |
|
||||
| User storage | Protobuf file via Persister | Consistent with existing patterns |
|
||||
| Token expiry | 7-day access, 30-day refresh | Balance security and gaming UX |
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Proto Definitions & Infrastructure
|
||||
|
||||
**New files:**
|
||||
- `src/main/protobuf/net/eagle0/eagle/api/auth.proto` - Auth API messages
|
||||
- `src/main/protobuf/net/eagle0/eagle/internal/user.proto` - User storage schema
|
||||
|
||||
**Key proto messages:**
|
||||
```protobuf
|
||||
// API
|
||||
GetOAuthUrlRequest/Response // Get OAuth URL to open in browser
|
||||
ExchangeCodeRequest/Response // Exchange auth code for JWT
|
||||
SetDisplayNameRequest/Response // Set user's display name
|
||||
RefreshTokenRequest/Response // Refresh expired access token
|
||||
|
||||
// Internal storage
|
||||
User // user_id, display_name, oauth_identities
|
||||
UserDatabase // All users + indexes for lookup
|
||||
```
|
||||
|
||||
### Phase 2: Eagle Server Auth Services
|
||||
|
||||
**New Scala files:**
|
||||
- `src/main/scala/net/eagle0/eagle/auth/OAuthConfig.scala` - Discord/Google config from env vars
|
||||
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - JWT creation/validation (RS256)
|
||||
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD, display name validation
|
||||
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth code exchange
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC service implementation
|
||||
|
||||
**Modify:**
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthorizationInterceptor.scala`
|
||||
- Replace Basic Auth parsing with JWT validation
|
||||
- Skip auth for public endpoints (GetOAuthUrl, ExchangeCode, RefreshToken)
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthorizationUtils.scala`
|
||||
- Change context keys from `userName` to `userId` + `displayName`
|
||||
- `src/main/scala/net/eagle0/eagle/service/Main.scala`
|
||||
- Wire up new auth services and JWT key loading
|
||||
|
||||
### Phase 3: Unity Client OAuth Flow
|
||||
|
||||
**New C# files:**
|
||||
- `Assets/Auth/OAuthManager.cs` - OAuth flow + deep link handling
|
||||
- `Assets/Auth/TokenStorage.cs` - Secure token persistence
|
||||
- `Assets/Auth/AuthClient.cs` - gRPC client for auth service
|
||||
|
||||
**Modify:**
|
||||
- `Assets/EagleConnection.cs`
|
||||
- Replace `AuthInterceptor` (Basic Auth) with `JwtAuthInterceptor` (Bearer token)
|
||||
- `Assets/ConnectionHandler/ConnectionHandler.cs`
|
||||
- Replace username/password UI with Discord/Google login buttons
|
||||
- Add display name setup flow for new users
|
||||
|
||||
### Phase 4: Platform Configuration
|
||||
|
||||
**Deep link registration:**
|
||||
- iOS: Add `eagle0://` to CFBundleURLSchemes in Info.plist
|
||||
- Android: Add intent-filter for `eagle0://auth` in AndroidManifest.xml
|
||||
- Desktop: Register URL scheme (Windows registry / macOS plist)
|
||||
|
||||
**OAuth provider setup:**
|
||||
1. Discord Developer Portal: Create app, add redirect URI `eagle0://auth/callback`
|
||||
2. Google Cloud Console: Create OAuth client, add redirect URI
|
||||
|
||||
**Environment variables (server):**
|
||||
```
|
||||
DISCORD_CLIENT_ID
|
||||
DISCORD_CLIENT_SECRET
|
||||
GOOGLE_CLIENT_ID
|
||||
GOOGLE_CLIENT_SECRET
|
||||
JWT_PRIVATE_KEY_PATH
|
||||
JWT_PUBLIC_KEY_PATH
|
||||
```
|
||||
|
||||
### Phase 5: Testing
|
||||
|
||||
**Unit tests:**
|
||||
- `JwtServiceSpec.scala` - Token creation/validation
|
||||
- `UserServiceSpec.scala` - Display name validation, uniqueness
|
||||
- `OAuthServiceSpec.scala` - OAuth flow with mocked providers
|
||||
|
||||
**Integration tests:**
|
||||
- Full OAuth flow with mock provider
|
||||
- JWT validation in AuthorizationInterceptor
|
||||
- gRPC calls with valid/invalid tokens
|
||||
|
||||
**Manual testing:**
|
||||
- [ ] Discord login (Windows, macOS)
|
||||
- [ ] Google login (Windows, macOS)
|
||||
- [ ] Deep link callback works
|
||||
- [ ] Display name validation
|
||||
- [ ] Session persistence across restarts
|
||||
- [ ] Token refresh
|
||||
|
||||
## Files Summary
|
||||
|
||||
### Create
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/main/protobuf/net/eagle0/eagle/api/auth.proto` | Auth API definitions |
|
||||
| `src/main/protobuf/net/eagle0/eagle/internal/user.proto` | User storage schema |
|
||||
| `src/main/scala/net/eagle0/eagle/auth/OAuthConfig.scala` | Provider config |
|
||||
| `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` | JWT handling |
|
||||
| `src/main/scala/net/eagle0/eagle/auth/UserService.scala` | User management |
|
||||
| `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` | OAuth flow |
|
||||
| `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` | gRPC service |
|
||||
| `Assets/Auth/OAuthManager.cs` | Unity OAuth manager |
|
||||
| `Assets/Auth/TokenStorage.cs` | Token storage |
|
||||
| `Assets/Auth/AuthClient.cs` | Auth gRPC client |
|
||||
|
||||
### Modify
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `AuthorizationInterceptor.scala` | Basic Auth -> JWT validation |
|
||||
| `AuthorizationUtils.scala` | userName -> userId + displayName |
|
||||
| `Main.scala` | Wire auth services |
|
||||
| `EagleConnection.cs` | AuthInterceptor -> JwtAuthInterceptor |
|
||||
| `ConnectionHandler.cs` | Login UI -> OAuth buttons + display name |
|
||||
|
||||
### Delete
|
||||
- nginx htpasswd configuration (no longer needed)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **State parameter** - CSRF protection in OAuth flow
|
||||
2. **PKCE** - Consider adding for mobile (enhancement)
|
||||
3. **Secure storage** - Use Keychain (iOS) / Keystore (Android) for tokens
|
||||
4. **Token refresh** - 7-day access tokens with 30-day refresh
|
||||
5. **Rate limiting** - Limit login attempts per IP
|
||||
|
||||
## Dependencies to Add
|
||||
|
||||
**Scala (MODULE.bazel):**
|
||||
- JWT library (e.g., `jwt-scala` or `nimbus-jose-jwt`)
|
||||
- HTTP client (e.g., `sttp` for OAuth requests)
|
||||
|
||||
**Unity:**
|
||||
- Deep linking is built-in (Unity 2021+)
|
||||
- No additional packages required
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
Keep Basic Auth code in a feature branch. Both auth methods can coexist during transition via feature flag if needed.
|
||||
@@ -1,350 +0,0 @@
|
||||
# OAuth Implementation: Next Steps and Design
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The OAuth implementation is functional but has several gaps that need addressing before it's production-ready. This document outlines the known issues, proposes a comprehensive user identity model, and provides a prioritized implementation plan.
|
||||
|
||||
## Current State (Updated January 2026)
|
||||
|
||||
### What Works ✅
|
||||
- Discord OAuth flow (server-mediated polling)
|
||||
- Google OAuth flow
|
||||
- JWT token generation and validation
|
||||
- User creation and display name setting
|
||||
- Auto-login with stored tokens
|
||||
- Basic game creation and play with OAuth users
|
||||
- Headshot fetching via public CDN (no auth required)
|
||||
- Logout button in lobby (preserves tokens for quick reconnect)
|
||||
- Environment (prod/qa) and user display in lobby
|
||||
- Game identity with userName = displayName (PR #4964 merged)
|
||||
|
||||
### Known Issues
|
||||
|
||||
#### 1. Game Identity Model Fragility (Deferred)
|
||||
**Status**: Accepted for now. PR #4964 merged with `userName = displayName`.
|
||||
|
||||
**Current behavior**:
|
||||
- Games store `userNameToFactionId: Map[String, Int]`
|
||||
- For JWT users, this maps displayName → factionId
|
||||
- displayName is technically mutable (users could change it)
|
||||
- No migration path when displayName changes
|
||||
|
||||
**Why this is acceptable**:
|
||||
1. We don't currently have a "change display name" feature
|
||||
2. The alternative (using userId) requires more extensive changes
|
||||
3. Can migrate to userId-based identity later if needed
|
||||
|
||||
#### 2. In-Game Headshot Fetching ✅ FIXED
|
||||
**Solution**: Made the `eagle0-headshots` S3 bucket public and enabled CDN.
|
||||
- Client now fetches directly from `https://eagle0-headshots.sfo3.cdn.digitaloceanspaces.com/`
|
||||
- No authentication required
|
||||
- Works for both OAuth and Basic Auth users
|
||||
- Simpler architecture, no dependency on home Mac server
|
||||
|
||||
#### 3. Logout from Lobby ✅ FIXED
|
||||
**Solution**: Added logout button to lobby UI (PR #4967).
|
||||
- Button disconnects from server and returns to connection screen
|
||||
- Intentionally does NOT clear OAuth tokens
|
||||
- Allows quick reconnect with same account without full OAuth flow
|
||||
|
||||
#### 4. Display Name Uniqueness Not Enforced (Medium) - OPEN
|
||||
**Problem**: User was able to set displayName "nolen" when that name was already taken.
|
||||
|
||||
**Root cause**: Unknown - needs investigation. Either:
|
||||
- The uniqueness check is buggy
|
||||
- The displayNameIndex wasn't populated correctly during user creation
|
||||
- Race condition during concurrent registrations
|
||||
|
||||
#### 5. Admin Server Crashes ✅ FIXED
|
||||
**Solution**: PR #4964 sets `userName = displayName` for JWT users.
|
||||
|
||||
#### 6. Intermittent "Expired" Errors During Login (Medium) - INVESTIGATING
|
||||
**Problem**: Users occasionally get "OAuth session expired" errors even when server logs show the callback succeeded.
|
||||
|
||||
**Status**: Added diagnostic logging in PR #4974 to trace:
|
||||
- State creation in `getAuthUrl`
|
||||
- State lookup in `handleCallback`
|
||||
- Result lookup in `checkStatus`
|
||||
|
||||
**Possible causes**:
|
||||
- State mismatch between client and server
|
||||
- Race condition in polling
|
||||
- Cleanup running at wrong time
|
||||
|
||||
#### 7. Token Expiry Field Bug ✅ FIXED
|
||||
**Problem**: `CheckOAuthStatusResponse.expiresAt` was returning refresh token expiry (30 days) instead of access token expiry (7 days).
|
||||
|
||||
**Solution**: Fixed in PR #4974 to calculate correct access token expiry.
|
||||
|
||||
---
|
||||
|
||||
## Proposed User Identity Model
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Stable Internal Identity**: `userId` (UUID) is the only key used for persistent associations
|
||||
2. **Display Name is Cosmetic**: Can change without breaking game associations
|
||||
3. **Backwards Compatibility**: Basic Auth continues to work for local development
|
||||
4. **Multi-Provider Support**: Users can link Discord, Google, and future providers
|
||||
5. **Avatar Flexibility**: Use OAuth avatar by default, support custom uploads later
|
||||
|
||||
### Data Model
|
||||
|
||||
```
|
||||
User {
|
||||
userId: String (UUID) // Primary key, immutable, used for all internal references
|
||||
displayName: String // Unique, user-visible, mutable with migration
|
||||
displayNameLower: String // Case-insensitive uniqueness
|
||||
email: String // Primary email for account recovery/linking
|
||||
avatarUrl: String // Current avatar URL
|
||||
avatarData: bytes // Cached avatar for offline/fast access (future)
|
||||
oauthIdentities: [OAuthIdentity]
|
||||
createdAt: Timestamp
|
||||
lastLoginAt: Timestamp
|
||||
isAdmin: Boolean
|
||||
}
|
||||
|
||||
OAuthIdentity {
|
||||
provider: String // "discord", "google", etc.
|
||||
providerUserId: String // Provider's user ID
|
||||
providerEmail: String // Email from this provider
|
||||
avatarUrl: String // Avatar from this provider
|
||||
linkedAt: Timestamp
|
||||
}
|
||||
```
|
||||
|
||||
### Identity Resolution Strategy
|
||||
|
||||
The key question: **What should `AuthorizationUtils.userName` return?**
|
||||
|
||||
#### Option A: userName = displayName (Current PR #4964)
|
||||
- **Pro**: Human-readable in logs, game saves, debugging
|
||||
- **Con**: Breaks if displayName changes
|
||||
- **Migration**: None needed now, complex later
|
||||
|
||||
#### Option B: userName = userId (Recommended)
|
||||
- **Pro**: Stable identity, displayName changes are safe
|
||||
- **Con**: UUIDs in logs are ugly, need display name lookup for UI
|
||||
- **Migration**: Cleaner long-term, but breaking change for any existing OAuth games
|
||||
|
||||
#### Option C: Hybrid with Migration Support
|
||||
- **userName** = userId for new games
|
||||
- **Legacy lookup** for old games by displayName
|
||||
- **Display layer** resolves userId → displayName for UI
|
||||
|
||||
**Recommendation**: Option B with a display name resolution layer. The ugliness in logs is acceptable for the stability it provides. Implement a `UserService.resolveDisplayName(identifier: String): String` that returns displayName for UUIDs or the identifier itself for legacy usernames.
|
||||
|
||||
### Account Linking Strategy
|
||||
|
||||
#### Automatic Linking (Future)
|
||||
When a user logs in with a new OAuth provider:
|
||||
1. Check if the provider email matches an existing user's email
|
||||
2. If match found, prompt: "An account exists with this email. Link accounts?"
|
||||
3. If confirmed, add new OAuthIdentity to existing user
|
||||
4. If declined, create separate account (different email required)
|
||||
|
||||
#### Manual Linking (MVP)
|
||||
1. User logs in with primary account
|
||||
2. User goes to Settings → Linked Accounts
|
||||
3. User clicks "Link Discord" or "Link Google"
|
||||
4. OAuth flow adds new identity to current user
|
||||
|
||||
### Avatar/Headshot Strategy
|
||||
|
||||
#### Phase 1: OAuth Avatars (MVP)
|
||||
- Store `avatarUrl` from OAuth provider during login
|
||||
- Server proxies avatar requests to avoid CORS issues
|
||||
- Cache avatars locally with TTL
|
||||
|
||||
#### Phase 2: Avatar Caching
|
||||
- Download avatar to local storage on login
|
||||
- Serve from local storage for reliability
|
||||
- Refresh periodically or on login
|
||||
|
||||
#### Phase 3: Custom Avatars (Future)
|
||||
- Allow users to upload custom avatar
|
||||
- Store in S3/DO Spaces
|
||||
- Custom avatar overrides OAuth avatar
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Stabilization ✅ COMPLETE
|
||||
|
||||
#### 1.1 Fix Display Name Uniqueness Bug - OPEN
|
||||
- [ ] Investigate why "nolen" was allowed when it existed
|
||||
- [ ] Add logging to `setDisplayName` to trace the issue
|
||||
- [ ] Ensure `displayNameIndex` is correctly maintained
|
||||
- [ ] Add unit tests for uniqueness enforcement
|
||||
|
||||
#### 1.2 Add Logout Button to Lobby ✅ DONE
|
||||
- [x] Add "Logout" button to lobby UI
|
||||
- [x] Disconnect from server
|
||||
- [x] Navigate to connection screen
|
||||
- [x] Preserve OAuth tokens for quick reconnect (intentional change from original plan)
|
||||
|
||||
#### 1.3 Merge PR #4964 (userName = displayName) ✅ DONE
|
||||
- [x] Merged - games work with OAuth users
|
||||
- [x] Documented limitation (games break if displayName changes)
|
||||
|
||||
#### 1.4 Fix Headshot Fetching ✅ DONE
|
||||
- [x] Made eagle0-headshots bucket public
|
||||
- [x] Client fetches directly from CDN
|
||||
- [x] No authentication required
|
||||
|
||||
#### 1.5 Add Lobby Status Display ✅ DONE
|
||||
- [x] Show environment (prod/qa) in lobby
|
||||
- [x] Show current user in lobby (OAuth displayName or classic username)
|
||||
|
||||
### Phase 2: Remaining Work (Priority Order)
|
||||
|
||||
#### 2.1 Diagnose Intermittent "Expired" Errors - IN PROGRESS
|
||||
- [x] Add diagnostic logging (PR #4974)
|
||||
- [ ] Deploy and reproduce the issue
|
||||
- [ ] Analyze logs to identify root cause
|
||||
- [ ] Implement fix based on findings
|
||||
|
||||
#### 2.2 Fix Display Name Uniqueness
|
||||
- [ ] Investigate UserService.setDisplayName logic
|
||||
- [ ] Check displayNameIndex population
|
||||
- [ ] Add logging to trace the issue
|
||||
- [ ] Fix the bug and add tests
|
||||
|
||||
#### 2.3 Wire Up Lobby UI in Unity
|
||||
- [ ] Connect `lobbyEnvironmentText` to TextMeshProUGUI in scene
|
||||
- [ ] Connect `lobbyUserText` to TextMeshProUGUI in scene
|
||||
|
||||
#### 2.4 Implement Token Refresh During Gameplay
|
||||
- [ ] Implement `RefreshToken` RPC on server (currently throws UNIMPLEMENTED)
|
||||
- [ ] Store refresh tokens server-side for validation
|
||||
- [ ] Add proactive refresh in client before token expires
|
||||
- [ ] Handle refresh during reconnection attempts
|
||||
|
||||
### Phase 3: Nice-to-Haves (Future)
|
||||
|
||||
#### 3.1 Proactive Token Refresh
|
||||
- [ ] Monitor token expiry in client
|
||||
- [ ] Refresh automatically when < 5 minutes remaining
|
||||
- [ ] Update TokenStorage with new access token
|
||||
|
||||
#### 3.2 Better Error Messages
|
||||
- [ ] Distinguish between network errors and auth errors
|
||||
- [ ] Show user-friendly messages for OAuth failures
|
||||
- [ ] Add retry suggestions
|
||||
|
||||
#### 3.3 Session Persistence Across Server Restarts
|
||||
- [ ] Move pendingOAuth from in-memory TrieMap to Redis/database
|
||||
- [ ] Move completedOAuth to Redis with TTL
|
||||
- [ ] Server can restart without breaking in-flight OAuth flows
|
||||
|
||||
#### 3.4 Migrate to userId-based Game Identity (Deferred)
|
||||
- [ ] Change `AuthorizationUtils.userName` to return `userId` for JWT users
|
||||
- [ ] Add `UserService.resolveDisplayName(id: String): String` for UI display
|
||||
- [ ] Update game UI to resolve userIds to displayNames
|
||||
- [ ] Existing Basic Auth games continue to work (userName is literal)
|
||||
|
||||
#### 3.5 Display Name Change Support (Requires 3.4)
|
||||
- [ ] Add `ChangeDisplayName` RPC
|
||||
- [ ] Validate new name is unique
|
||||
- [ ] Update user record
|
||||
- [ ] No game migration needed (games use userId)
|
||||
|
||||
### Phase 3: Multi-Provider Support (Future)
|
||||
|
||||
#### 3.1 Account Linking UI
|
||||
- [ ] Add Settings page with "Linked Accounts" section
|
||||
- [ ] Show currently linked providers
|
||||
- [ ] "Link Another Account" button triggers OAuth flow
|
||||
- [ ] `LinkOAuthProvider` RPC adds identity to current user
|
||||
|
||||
#### 3.2 Login Provider Selection
|
||||
- [ ] If user has multiple providers, any can be used to login
|
||||
- [ ] All resolve to same userId
|
||||
- [ ] Session shows which provider was used
|
||||
|
||||
#### 3.3 Account Merging (Complex)
|
||||
- [ ] Handle case where user created separate accounts
|
||||
- [ ] Merge game history, stats, etc.
|
||||
- [ ] Delete duplicate user record
|
||||
- [ ] This is complex - may defer or not implement
|
||||
|
||||
### Phase 4: Enhanced Avatars (Future)
|
||||
|
||||
#### 4.1 Avatar Caching
|
||||
- [ ] Download avatars to S3/DO Spaces on login
|
||||
- [ ] Serve from our CDN
|
||||
- [ ] Refresh on login if changed
|
||||
|
||||
#### 4.2 Custom Avatar Upload
|
||||
- [ ] Upload endpoint with size/format validation
|
||||
- [ ] Store in S3/DO Spaces
|
||||
- [ ] Custom avatar overrides OAuth avatar
|
||||
|
||||
---
|
||||
|
||||
## Technical Debt to Address
|
||||
|
||||
1. **Context Propagation in Futures**: PR #4960 fixed `setDisplayName` and `getCurrentUser`, but audit all `Future` blocks that access `AuthorizationUtils`
|
||||
|
||||
2. **Dual Auth Support**: The system supports both Basic Auth and JWT. Consider:
|
||||
- Should Basic Auth be deprecated for production?
|
||||
- Should it remain for local development only?
|
||||
- How do Basic Auth users interact with OAuth users in the same game?
|
||||
|
||||
3. **Token Refresh**: `RefreshToken` RPC throws UNIMPLEMENTED. Need to:
|
||||
- Implement refresh token storage and validation
|
||||
- Handle token refresh in client
|
||||
- Consider refresh token rotation for security
|
||||
|
||||
4. **Session Management**: No server-side session tracking. Consider:
|
||||
- Track active sessions per user
|
||||
- Allow "logout all devices"
|
||||
- Detect concurrent logins
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **What happens when a Basic Auth user and OAuth user have the same name?**
|
||||
- Currently possible - Basic Auth doesn't check UserService
|
||||
- Could cause confusion in games
|
||||
- Solution: Require OAuth for multiplayer? Or namespace Basic Auth names?
|
||||
|
||||
2. **Should displayName changes be allowed?**
|
||||
- With userId-based identity, it's safe
|
||||
- But could cause confusion ("who is this new player?")
|
||||
- Consider: rate limit changes, show "formerly known as" temporarily
|
||||
|
||||
3. **How to handle OAuth provider account deletion?**
|
||||
- User deletes their Discord account
|
||||
- Their Eagle0 account still exists
|
||||
- They can't login unless they linked another provider
|
||||
- Solution: Encourage linking multiple providers, or add email/password fallback
|
||||
|
||||
4. **Admin impersonation with OAuth**
|
||||
- Currently works via X-Impersonate-User header
|
||||
- Should this use userId or displayName?
|
||||
- Probably userId for stability
|
||||
|
||||
---
|
||||
|
||||
## Appendix: File Locations
|
||||
|
||||
### Server (Scala)
|
||||
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD
|
||||
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - Token generation/validation
|
||||
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth flow
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC Auth service
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthorizationInterceptor.scala` - Auth middleware
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthorizationUtils.scala` - Context accessors
|
||||
|
||||
### Client (C#)
|
||||
- `Assets/Auth/AuthClient.cs` - gRPC client for Auth service
|
||||
- `Assets/Auth/OAuthManager.cs` - OAuth flow orchestration
|
||||
- `Assets/Auth/TokenStorage.cs` - Persistent token storage
|
||||
- `Assets/Auth/JwtAuthInterceptor.cs` - Attaches JWT to requests
|
||||
|
||||
### Protos
|
||||
- `src/main/protobuf/net/eagle0/eagle/api/auth.proto` - Auth service definition
|
||||
- `src/main/protobuf/net/eagle0/eagle/internal/user/user.proto` - User data model
|
||||
@@ -1,205 +0,0 @@
|
||||
# Scala 3 Modernization Guide
|
||||
|
||||
## Overview
|
||||
This document outlines opportunities to modernize the Eagle0 codebase to use Scala 3 best practices and features. The migration to Scala 3 is complete, but the code still uses many Scala 2 patterns that can be improved.
|
||||
|
||||
## Modernization Opportunities
|
||||
|
||||
### 1. **Convert Sealed Traits to Enums** 🎯 HIGH IMPACT
|
||||
**Benefits**: Better performance, more concise syntax, improved exhaustiveness checking
|
||||
|
||||
**Current pattern** (`ExternalTextGenerationCaller.scala:23-31`):
|
||||
```scala
|
||||
sealed trait ExternalTextGenerationError extends Error {
|
||||
def message: String
|
||||
}
|
||||
case class ExternalTextGenerationRateLimitError(code: Int, message: String)
|
||||
extends ExternalTextGenerationError
|
||||
case class ExternalTextGenerationHttpError(code: Int, message: String)
|
||||
extends ExternalTextGenerationError
|
||||
case class ExternalTextGenerationTimeoutError(message: String)
|
||||
extends ExternalTextGenerationError
|
||||
```
|
||||
|
||||
**Scala 3 improvement**:
|
||||
```scala
|
||||
enum ExternalTextGenerationError extends Error:
|
||||
case RateLimit(code: Int, message: String)
|
||||
case Http(code: Int, message: String)
|
||||
case Timeout(message: String)
|
||||
|
||||
def message: String = this match
|
||||
case RateLimit(_, msg) => msg
|
||||
case Http(_, msg) => msg
|
||||
case Timeout(msg) => msg
|
||||
```
|
||||
|
||||
**Files to check**:
|
||||
- `/src/main/scala/net/eagle0/common/llm_integration/ExternalTextGenerationCaller.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request/GeneratedTextRequestT.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/model/state/quest/concrete/QuestC.scala`
|
||||
|
||||
### 2. **Convert Implicit Classes to Extension Methods** 🎯 HIGH IMPACT
|
||||
**Benefits**: Modern syntax, better IDE support, cleaner imports
|
||||
|
||||
**Current pattern** (`MoreSeq.scala:23-26`):
|
||||
```scala
|
||||
implicit def SeqCollect[A, Repr[_]](coll: Repr[A])(implicit
|
||||
itr: IsIterable[Repr[A]]
|
||||
): SeqCollect[A, Repr, itr.type] =
|
||||
new SeqCollect[A, Repr, itr.type](coll, itr)
|
||||
```
|
||||
|
||||
**Scala 3 improvement**:
|
||||
```scala
|
||||
extension [A, Repr[_]](coll: Repr[A])(using itr: IsIterable[Repr[A]])
|
||||
def flatCollect[B](pf: PartialFunction[itr.A, Option[B]])(using Factory[B, Repr[B]]): Repr[B] =
|
||||
Factory[B, Repr[B]].fromSpecific(itr(coll).collect(pf).flatten)
|
||||
|
||||
def flatCollectFirst[B](pf: PartialFunction[itr.A, Option[B]]): Option[B] =
|
||||
itr(coll).collect(pf).flatten.headOption
|
||||
```
|
||||
|
||||
**Files to check**:
|
||||
- `/src/main/scala/net/eagle0/common/MoreSeq.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/CommandChooser.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/service/new_game_creation/NewGameCreation.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultProtoApplierImpl.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/service/new_game_creation/StartGameActionResultUtils.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/model/state/date/Date.scala`
|
||||
|
||||
### 3. **Convert Implicit Parameters to Using Clauses** 🎯 MEDIUM IMPACT
|
||||
**Benefits**: Cleaner syntax, better tooling support, clearer intent
|
||||
|
||||
**Current pattern**:
|
||||
```scala
|
||||
def method[T](value: T)(implicit ec: ExecutionContext): Future[T]
|
||||
def process[A](items: Seq[A])(implicit ord: Ordering[A]): Seq[A]
|
||||
```
|
||||
|
||||
**Scala 3 improvement**:
|
||||
```scala
|
||||
def method[T](value: T)(using ExecutionContext): Future[T]
|
||||
def process[A](items: Seq[A])(using Ordering[A]): Seq[A]
|
||||
```
|
||||
|
||||
**Files to check**:
|
||||
- `/src/main/scala/net/eagle0/common/MoreSeq.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/library/util/hero_name_fetcher/HeroNameFetcher.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/library/util/ShardokMapInfo.scala`
|
||||
- `/src/main/scala/net/eagle0/common/llm_integration/OpenAIChatCompletionsServiceImpl.scala`
|
||||
- `/src/main/scala/net/eagle0/common/llm_integration/ClaudeServiceImpl.scala`
|
||||
|
||||
### 4. **Opaque Types for Type Safety** 🎯 MEDIUM IMPACT
|
||||
**Benefits**: Zero runtime cost, compile-time type safety, prevents mixing up similar types
|
||||
|
||||
**Pattern to look for**: Type aliases that represent distinct concepts
|
||||
```scala
|
||||
// Instead of: type UserId = String, type GameId = String
|
||||
opaque type UserId = String
|
||||
object UserId:
|
||||
def apply(s: String): UserId = s
|
||||
extension (id: UserId)
|
||||
def value: String = id
|
||||
def isValid: Boolean = id.nonEmpty && id.length > 3
|
||||
|
||||
opaque type GameId = Long
|
||||
object GameId:
|
||||
def apply(l: Long): GameId = l
|
||||
extension (id: GameId) def value: Long = id
|
||||
```
|
||||
|
||||
**Candidates**: Look for simple type aliases and ID types throughout the codebase.
|
||||
|
||||
### 5. **Inline Methods for Performance** 🎯 LOW IMPACT
|
||||
**Benefits**: Compile-time optimization, better performance for hot paths
|
||||
|
||||
**Pattern**: Mark small, frequently-called methods as `inline`
|
||||
```scala
|
||||
inline def isValidId(id: String): Boolean =
|
||||
id.nonEmpty && id.length > 3
|
||||
|
||||
inline def calculateScore(base: Int, multiplier: Double): Double =
|
||||
base * multiplier
|
||||
```
|
||||
|
||||
**Candidates**: Small utility methods in performance-critical paths (AI calculations, game state updates).
|
||||
|
||||
### 6. **Union Types Instead of Complex Hierarchies** 🎯 LOW IMPACT
|
||||
**Benefits**: Simpler type definitions for either/or scenarios
|
||||
|
||||
**Pattern**: Simple sealed traits with only case classes
|
||||
```scala
|
||||
// Instead of:
|
||||
sealed trait Result
|
||||
case class Success(value: String) extends Result
|
||||
case class Error(message: String) extends Result
|
||||
|
||||
// Consider:
|
||||
type Result = Success | Error
|
||||
case class Success(value: String)
|
||||
case class Error(message: String)
|
||||
```
|
||||
|
||||
### 7. **Context Functions for Cleaner APIs** 🎯 LOW IMPACT
|
||||
**Benefits**: Cleaner API design, implicit context passing
|
||||
|
||||
**Pattern**: Replace implicit function parameters
|
||||
```scala
|
||||
// Old
|
||||
type Handler = GameState => Unit
|
||||
def withGameState(gs: GameState)(handler: Handler): Unit = handler(gs)
|
||||
|
||||
// New
|
||||
type Handler = GameState ?=> Unit
|
||||
def withGameState(gs: GameState)(handler: Handler): Unit =
|
||||
given GameState = gs
|
||||
handler
|
||||
```
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
### Phase 1: Quick Wins (High Impact, Low Risk)
|
||||
1. **Convert Extension Methods** in `MoreSeq.scala` - immediate readability improvement
|
||||
2. **Update Using Clauses** - simple find/replace operation
|
||||
3. **Convert Simple Sealed Traits to Enums** - start with error types
|
||||
|
||||
### Phase 2: Type Safety Improvements
|
||||
4. **Add Opaque Types** for IDs and measurements - improves type safety
|
||||
5. **Inline Performance-Critical Methods** - measure before/after impact
|
||||
|
||||
### Phase 3: Advanced Features (Lower Priority)
|
||||
6. **Union Types** where appropriate - only for simple either/or cases
|
||||
7. **Context Functions** for complex API improvements
|
||||
|
||||
## Implementation Guidelines
|
||||
|
||||
### Style Consistency
|
||||
- **Keep curly braces**: Continue using Scala 2 style `{}` instead of indentation-based syntax
|
||||
- **Gradual adoption**: Modernize files as they're touched for other reasons
|
||||
- **Test thoroughly**: Each modernization should include verification that behavior is unchanged
|
||||
|
||||
### Performance Considerations
|
||||
- **Measure enum performance**: Verify that enum conversion actually improves performance in hot paths
|
||||
- **Benchmark inline methods**: Use profiling to confirm performance gains
|
||||
- **Consider compilation time**: Some features may increase compile time
|
||||
|
||||
### Migration Strategy
|
||||
- **File-by-file approach**: Complete modernization of one file at a time
|
||||
- **Separate PRs**: Each modernization type should be its own PR for easier review
|
||||
- **Documentation**: Update this document as patterns are modernized
|
||||
|
||||
## Success Criteria
|
||||
- [ ] All extension methods converted from implicit classes
|
||||
- [ ] All implicit parameters converted to using clauses
|
||||
- [ ] Key sealed traits converted to enums where appropriate
|
||||
- [ ] Opaque types introduced for important ID types
|
||||
- [ ] Performance-critical methods marked as inline (with benchmarks)
|
||||
- [ ] No regression in functionality or performance
|
||||
- [ ] Code remains readable and maintainable
|
||||
|
||||
## Notes
|
||||
- Focus on high-impact, low-risk improvements first
|
||||
- Each change should be driven by clear benefits (performance, readability, type safety)
|
||||
- Maintain backward compatibility where possible
|
||||
- Document any breaking changes clearly
|
||||
@@ -15,10 +15,10 @@ 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~~
|
||||
- [ ] Fix the Mac installer
|
||||
- [x] Fix the Mac installer
|
||||
- [x] ~~Still not reconnecting after deployments~~
|
||||
- [ ] Notify about client updates, button to come directly back
|
||||
- [ ] Generatedtext healing
|
||||
- [x] Generatedtext healing
|
||||
- [ ] Kill outstanding shardok requests when game is deleted
|
||||
|
||||
### Other Productionization
|
||||
@@ -44,8 +44,8 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
|
||||
|
||||
- [ ] 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
|
||||
- [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
|
||||
- [ ] Privacy policy (collecting accounts, OAuth data, gameplay data)
|
||||
- [ ] Terms of service (basic liability protection)
|
||||
- [ ] Data deletion capability (user requests account removal)
|
||||
@@ -53,13 +53,13 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
|
||||
### Basic Gameplay
|
||||
|
||||
- [ ] Tutorial
|
||||
- [ ] In the Your Warlord panel, say what the profession is
|
||||
- [x] In the Your Warlord panel, say what the profession is
|
||||
- [x] ~~And separate panels for each profession when you encounter one~~
|
||||
- [x] ~~Command tutorial for each command the first time it's clicked~~
|
||||
- [x] ~~Time to recruit / expand~~
|
||||
- [x] ~~And how expansion works~~
|
||||
- [ ] Province events
|
||||
- [ ] Running low on food
|
||||
- [x] Running low on food
|
||||
- [x] ~~Time to swear brotherhood~~
|
||||
- [x] ~~When you get large, or~~
|
||||
- [x] ~~When you get a good candidate~~
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
# Scala 3 Migration: Reflection Issues Found
|
||||
|
||||
This document catalogs all reflection-related problems discovered during the Scala 2.13.16 → Scala 3.7.2 migration of the Eagle0 codebase.
|
||||
|
||||
## Summary
|
||||
|
||||
The migration revealed several categories of reflection issues that needed to be addressed for Scala 3 compatibility:
|
||||
|
||||
1. **Scala 2 Runtime Reflection API** - No longer available in Scala 3
|
||||
2. **Settings System Reflection** - Custom reflection for loading settings singletons
|
||||
3. **json4s Automatic Case Class Extraction** - Uses reflection that fails with Scala 3 metaprogramming classes
|
||||
4. **ScalaTest Exception Handling** - Syntax changes affecting exception variable binding
|
||||
|
||||
## 1. Scala 2 Runtime Reflection (FIXED)
|
||||
|
||||
### Issue
|
||||
Tests using `scala.reflect.runtime.universe` fail because this reflection API doesn't exist in Scala 3.
|
||||
|
||||
### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/test/scala/net/eagle0/eagle/library/actions/types/ActionResultTypesTest.scala`
|
||||
|
||||
### Error
|
||||
```scala
|
||||
import scala.reflect.runtime.universe // Not available in Scala 3
|
||||
```
|
||||
|
||||
### Solution Applied
|
||||
**Deleted the test entirely** as it was redundant. The test was verifying that auto-generated Scala objects (created by Bazel from proto enum values) matched their source proto values - something already guaranteed by the build system. Since the objects are generated directly from the proto definitions, this test provided no value.
|
||||
|
||||
**Files deleted:**
|
||||
- `src/test/scala/net/eagle0/eagle/library/actions/types/ActionResultTypesTest.scala`
|
||||
|
||||
## 2. Settings System Reflection (FIXED)
|
||||
|
||||
### Issue
|
||||
Custom `SettingsLoader` class used reflection to access Scala object singletons, but the reflection pattern changed between Scala 2 and Scala 3.
|
||||
|
||||
### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/library/settings/loaders/SettingsLoader.scala`
|
||||
|
||||
### Error
|
||||
```
|
||||
java.lang.NoSuchMethodException: net.eagle0.eagle.library.settings.ApprehendOutlawVigorCost$.MODULE$
|
||||
```
|
||||
|
||||
### Root Cause
|
||||
In Scala 2, singleton objects are accessed via `ClassName$.MODULE$()`, but in Scala 3, they're accessed directly via `ClassName$` field. Additionally, `scala.reflect.runtime.universe` is not available in Scala 3.
|
||||
|
||||
### Solution Applied
|
||||
**Completely eliminated reflection** by auto-generating the entire `SettingsLoader.scala` file from BUILD.bazel definitions:
|
||||
|
||||
1. **Created generator**: `src/main/go/net/eagle0/build/settings_loader_generator/settings_loader_generator.go` - parses BUILD.bazel and generates complete SettingsLoader.scala with pattern matching for all 272 settings
|
||||
|
||||
2. **Added genrule**: In `src/main/scala/net/eagle0/eagle/library/settings/loaders/BUILD.bazel`:
|
||||
```python
|
||||
genrule(
|
||||
name = "settings_loader_src",
|
||||
srcs = ["//src/main/scala/net/eagle0/eagle/library/settings:BUILD.bazel"],
|
||||
outs = ["SettingsLoader.scala"],
|
||||
cmd = "$(location //src/main/go/net/eagle0/build/settings_loader_generator) $(location //src/main/scala/net/eagle0/eagle/library/settings:BUILD.bazel) > $@",
|
||||
tools = ["//src/main/go/net/eagle0/build/settings_loader_generator"],
|
||||
)
|
||||
```
|
||||
|
||||
3. **Result**: SettingsLoader now uses compile-time pattern matching instead of reflection:
|
||||
```scala
|
||||
private def settingObjectForKey(key: String): Any = key match {
|
||||
case "ActionVigorCost" => ActionVigorCost
|
||||
case "BaseFoodBuyPrice" => BaseFoodBuyPrice
|
||||
// ... all 272 settings auto-generated
|
||||
case _ => throw NoSuchSettingException(key)
|
||||
}
|
||||
```
|
||||
|
||||
### Benefits
|
||||
- **No reflection** - Completely Scala 3 compatible
|
||||
- **Maintainable** - New settings automatically included when added to BUILD.bazel
|
||||
- **Performance** - Pattern matching is faster than reflection
|
||||
- **Type-safe** - Compile-time checking of all settings
|
||||
|
||||
## 3. json4s Reflection Issues (MULTIPLE LOCATIONS)
|
||||
|
||||
### 3.1 EagleServiceImpl JSON Serialization (FIXED)
|
||||
|
||||
#### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/service/EagleServiceImpl.scala`
|
||||
|
||||
#### Error
|
||||
```
|
||||
java.lang.NoClassDefFoundError: scala/quoted/staging/package$
|
||||
```
|
||||
|
||||
#### Root Cause
|
||||
json4s automatic case class serialization uses reflection that tries to access Scala 3 metaprogramming classes (`scala.quoted.staging.package$`) which aren't available at runtime.
|
||||
|
||||
#### Solution Applied
|
||||
Replaced automatic json4s serialization with ScalaPB's built-in JSON support:
|
||||
|
||||
```scala
|
||||
// Old (reflection-based):
|
||||
// implicit val formats: DefaultFormats.type = DefaultFormats
|
||||
// write(actionResultView)
|
||||
|
||||
// New (ScalaPB JSON support):
|
||||
import scalapb.json4s.JsonFormat
|
||||
JsonFormat.toJsonString(actionResultView.toProto)
|
||||
```
|
||||
|
||||
### 3.2 ShardokMapInfo JSON Parsing (FIXED)
|
||||
|
||||
#### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/library/util/ShardokMapInfo.scala` (Line 44)
|
||||
|
||||
#### Error
|
||||
```
|
||||
java.lang.NoClassDefFoundError: scala/quoted/staging/package$
|
||||
at org.json4s.reflect.ScalaSigReader$.readConstructor(ScalaSigReader.scala:42)
|
||||
```
|
||||
|
||||
#### Root Cause
|
||||
The line `val extracted = parsedJson.extract[List[ShardokMapInfo]]` uses json4s automatic case class extraction which relies on reflection.
|
||||
|
||||
#### Solution Applied
|
||||
Replaced automatic extraction with manual JSON parsing:
|
||||
|
||||
```scala
|
||||
// OLD (reflection-based):
|
||||
val extracted = parsedJson.extract[List[ShardokMapInfo]]
|
||||
|
||||
// NEW (manual parsing, no reflection):
|
||||
val extracted = parsedJson match {
|
||||
case JArray(items) => items.map { item =>
|
||||
val name = (item \ "name").extract[String]
|
||||
val castleCount = (item \ "castleCount").extract[Int]
|
||||
val positions = (item \ "positions").extract[Map[Int, Int]]
|
||||
ShardokMapInfo(name, castleCount, positions)
|
||||
}
|
||||
case _ => throw new Exception("Expected JSON array for map info")
|
||||
}
|
||||
```
|
||||
|
||||
#### Testing
|
||||
The fix was verified - `attack_command_chooser_test` now passes successfully.
|
||||
|
||||
### 3.3 HeroNameFetcher JSON Parsing (FIXED)
|
||||
|
||||
#### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/library/util/hero_name_fetcher/HeroNameFetcher.scala`
|
||||
|
||||
#### Issue
|
||||
Case class extraction `parsedJson.extract[ResponseBody]` uses reflection that may fail in Scala 3.
|
||||
|
||||
#### Solution Applied
|
||||
Replaced automatic case class extraction with manual JSON parsing:
|
||||
|
||||
```scala
|
||||
// OLD (reflection-based):
|
||||
val parsedJson = json.parse(src.getLines().mkString)
|
||||
parsedJson.extract[ResponseBody]
|
||||
|
||||
// NEW (manual parsing, no reflection):
|
||||
parsedJson \ "names" match {
|
||||
case JArray(nameArray) =>
|
||||
nameArray.map { nameObj =>
|
||||
val id = (nameObj \ "id").extract[String]
|
||||
val name = (nameObj \ "name").extract[String]
|
||||
NameResponse(id, name)
|
||||
}.toVector
|
||||
case _ => throw new Exception("Expected 'names' array in response")
|
||||
}
|
||||
```
|
||||
|
||||
#### Testing
|
||||
The fix was verified - HeroNameFetcher now builds successfully without reflection.
|
||||
|
||||
### 3.4 Other json4s Usage Analysis
|
||||
|
||||
#### Files with json4s extraction:
|
||||
- **✅ SAFE**: OpenAI/Claude Services - Only extract simple types (`String`, `Int`) - no reflection
|
||||
- **✅ FIXED**: `HeroNameFetcher.scala` - Replaced `extract[ResponseBody]` with manual parsing (no reflection)
|
||||
- **⚠️ POTENTIAL ISSUES** (not currently causing failures but should be monitored):
|
||||
- `JsonUtils.scala`: `extract[Map[String, Vector[String]]]` - complex type extraction
|
||||
- `HexMapJsonUtils.scala`: `extract[List[JObject]]` - may be problematic
|
||||
|
||||
#### Recommendation
|
||||
Apply the same manual parsing pattern to remaining case class extractions if they cause runtime failures during Scala 3 migration.
|
||||
|
||||
## 4. ScalaTest Exception Handling Syntax (FIXED)
|
||||
|
||||
### Issue
|
||||
Scala 3 changed how exception variables are bound in ScalaTest's `the[Exception] thrownBy {...}` construct.
|
||||
|
||||
### Files Affected
|
||||
**70+ test files** across the codebase using exception testing patterns.
|
||||
|
||||
### Error Pattern
|
||||
```
|
||||
Not found: ex
|
||||
```
|
||||
|
||||
### Root Cause
|
||||
In Scala 2: `the[Exception] thrownBy { ... }` automatically creates an `ex` variable.
|
||||
In Scala 3: The exception variable must be explicitly bound.
|
||||
|
||||
### Solution Applied
|
||||
Added explicit variable binding across all affected test files:
|
||||
|
||||
```scala
|
||||
// Old Scala 2 syntax:
|
||||
the[EagleCommandException] thrownBy {
|
||||
// test code
|
||||
}
|
||||
ex.getMessage shouldBe "expected message"
|
||||
|
||||
// New Scala 3 syntax:
|
||||
val ex = the[EagleCommandException] thrownBy {
|
||||
// test code
|
||||
}
|
||||
ex.getMessage shouldBe "expected message"
|
||||
```
|
||||
|
||||
### Script Used
|
||||
Created and ran a systematic fix script that processed 70+ files:
|
||||
|
||||
```bash
|
||||
# Pattern to find and fix exception handling
|
||||
find . -name "*.scala" -exec sed -i '' 's/the\[\([^]]*\)\] thrownBy {/val ex = the[\1] thrownBy {/g' {} \;
|
||||
```
|
||||
|
||||
## 5. ScalaTest Import Changes (FIXED)
|
||||
|
||||
### Issue
|
||||
Scala 3 requires different imports for ScalaTest matchers.
|
||||
|
||||
### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/test/scala/net/eagle0/eagle/library/actions/impl/command/DeclineQuestCommandTest.scala`
|
||||
|
||||
### Error
|
||||
```
|
||||
value convertToAnyShouldWrapper is not a member of object org.scalatest.matchers.should.Matchers
|
||||
```
|
||||
|
||||
### Solution Applied
|
||||
Changed from specific imports to wildcard import:
|
||||
|
||||
```scala
|
||||
// Old:
|
||||
import org.scalatest.matchers.should.Matchers.{convertToAnyShouldWrapper, the}
|
||||
|
||||
// New:
|
||||
import org.scalatest.matchers.should.Matchers.*
|
||||
```
|
||||
|
||||
## 6. Mock Framework Issues (FIXED)
|
||||
|
||||
### Issue
|
||||
ScalaMock had type inference issues with Scala 3 for classes with constructor parameters.
|
||||
|
||||
### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/test/scala/net/eagle0/eagle/library/EngineImplTest.scala`
|
||||
|
||||
### Error
|
||||
```
|
||||
Found: Vector
|
||||
Required: Vector[net.eagle0.eagle.library.util.hero_generator.hero_with_name.HeroWithName]
|
||||
```
|
||||
|
||||
### Root Cause
|
||||
Mock framework couldn't properly infer types for `mock[HeroGenerator]` where `HeroGenerator` has constructor parameters.
|
||||
|
||||
### Solution Applied
|
||||
The user updated to a newer ScalaMock version that fixed this issue, plus added some missing Bazel dependencies:
|
||||
|
||||
```scala
|
||||
// Also needed to add missing dependency:
|
||||
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution"
|
||||
```
|
||||
|
||||
## Migration Status
|
||||
|
||||
### ✅ COMPLETED
|
||||
- [x] Scala 2 runtime reflection removal
|
||||
- [x] Settings system reflection compatibility
|
||||
- [x] EagleServiceImpl json4s → ScalaPB JSON
|
||||
- [x] ScalaTest exception handling syntax (70+ files)
|
||||
- [x] ScalaTest import changes
|
||||
- [x] Mock framework issues (via ScalaMock update)
|
||||
- [x] All test compilation issues resolved
|
||||
|
||||
### ⚠️ REMAINING
|
||||
- [ ] **Potential json4s case class extractions** - May cause runtime failures (JsonUtils, HexMapJsonUtils) - currently no test failures reported
|
||||
|
||||
### 📊 PROGRESS
|
||||
- **Tests passing**: All identified runtime failures resolved
|
||||
- **Build failures**: 0 (all tests now compile)
|
||||
- **Runtime failures**: 0 (critical ShardokMapInfo issue resolved)
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **✅ COMPLETED**: ShardokMapInfo json4s reflection issue resolved with manual parsing
|
||||
2. **Monitor remaining json4s usage**: Watch for runtime failures in HeroNameFetcher, JsonUtils, and HexMapJsonUtils during full Scala 3 migration
|
||||
3. **Consider ScalaPB for new JSON needs**: For new functionality, prefer ScalaPB's JSON support to avoid reflection entirely
|
||||
4. **Apply manual parsing pattern**: If other json4s case class extractions cause runtime failures, use the same manual parsing approach demonstrated in ShardokMapInfo
|
||||
|
||||
## Key Learnings
|
||||
|
||||
- **Scala 3 reflection changes**: Major differences in singleton object access patterns
|
||||
- **json4s compatibility**: Automatic case class extraction doesn't work well with Scala 3 metaprogramming
|
||||
- **ScalaPB advantage**: Using ScalaPB's JSON support avoids reflection issues entirely
|
||||
- **Systematic approach**: Many issues followed patterns that could be fixed with scripts across multiple files
|
||||
@@ -12,6 +12,7 @@ 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,6 +43,8 @@ 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=
|
||||
|
||||
@@ -5,24 +5,58 @@
|
||||
#
|
||||
# Environment variables:
|
||||
# SIGNING_IDENTITY - The signing identity (default: "Developer ID Application")
|
||||
# KEYCHAIN_PASSWORD - Password to unlock the build keychain (optional)
|
||||
# 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).
|
||||
|
||||
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 provided
|
||||
if [ -n "${KEYCHAIN_PASSWORD:-}" ]; then
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain || true
|
||||
# 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
|
||||
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
|
||||
|
||||
@@ -35,6 +35,7 @@ WARMUP_SCRIPT="${SCRIPT_DIR}/warmup-eagle.sh"
|
||||
SAVES_DIR="${APP_DIR}/saves"
|
||||
FLUSH_MARKER="${SAVES_DIR}/.flush_complete"
|
||||
DEPLOYMENT_IN_PROGRESS="${SAVES_DIR}/.deployment_in_progress"
|
||||
ACTIVE_INSTANCE_FILE="${APP_DIR}/.active-instance"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
@@ -298,6 +299,10 @@ main() {
|
||||
create_flush_marker "${deploy_id}" "eagle-${staging}"
|
||||
log_info "[DEPLOY:${deploy_id}] Flush marker created - server will auto-invalidate stale cache"
|
||||
|
||||
# Write active instance file for eagle-exec helper
|
||||
echo "eagle-${staging}" > "${ACTIVE_INSTANCE_FILE}"
|
||||
log_info "[DEPLOY:${deploy_id}] Active instance file updated: eagle-${staging}"
|
||||
|
||||
# Update .env for admin service
|
||||
local env_file="${APP_DIR}/.env"
|
||||
if [ "$staging" = "green" ]; then
|
||||
|
||||
+17
-25
@@ -1,43 +1,35 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Helper to run docker exec against the active Eagle instance.
|
||||
# Automatically determines whether blue or green is active based on nginx config.
|
||||
# Reads the active instance from /opt/eagle0/.active-instance (set by deploy-blue-green.sh).
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/eagle-exec.sh printenv GEMINI_API_KEY
|
||||
# ./scripts/eagle-exec.sh jcmd 1 VM.flags
|
||||
# ./scripts/eagle-exec.sh sh # Get a shell
|
||||
# eagle-exec printenv GEMINI_API_KEY
|
||||
# eagle-exec jcmd 1 VM.flags
|
||||
# eagle-exec sh # Get a shell
|
||||
#
|
||||
# Or create an alias:
|
||||
# To create an alias, add to ~/.bashrc:
|
||||
# alias eagle-exec='/opt/eagle0/scripts/eagle-exec.sh'
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="${APP_DIR:-/opt/eagle0}"
|
||||
ACTIVE_FILE="${APP_DIR}/.active-instance"
|
||||
|
||||
# Determine active instance from nginx config
|
||||
get_active_instance() {
|
||||
local backend
|
||||
backend=$(docker exec nginx grep -o 'eagle-[a-z]*:40032' /etc/nginx/nginx.conf 2>/dev/null | head -1 || echo "")
|
||||
|
||||
if [ "$backend" = "eagle-blue:40032" ]; then
|
||||
echo "eagle-blue"
|
||||
elif [ "$backend" = "eagle-green:40032" ]; then
|
||||
echo "eagle-green"
|
||||
# Read active instance from file, with fallback
|
||||
if [ -f "$ACTIVE_FILE" ]; then
|
||||
ACTIVE=$(cat "$ACTIVE_FILE")
|
||||
else
|
||||
# Fallback: check which container is actually running
|
||||
if docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null | grep -q true; then
|
||||
ACTIVE="eagle-blue"
|
||||
elif docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null | grep -q true; then
|
||||
ACTIVE="eagle-green"
|
||||
else
|
||||
# Fallback: check which container is actually running
|
||||
if docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null | grep -q true; then
|
||||
echo "eagle-blue"
|
||||
elif docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null | grep -q true; then
|
||||
echo "eagle-green"
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
ACTIVE=""
|
||||
fi
|
||||
}
|
||||
|
||||
ACTIVE=$(get_active_instance)
|
||||
fi
|
||||
|
||||
if [ -z "$ACTIVE" ]; then
|
||||
echo "Error: No active Eagle instance found" >&2
|
||||
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Helper to tail logs from the active Eagle instance.
|
||||
# Reads the active instance from /opt/eagle0/.active-instance (set by deploy-blue-green.sh).
|
||||
#
|
||||
# Usage:
|
||||
# eagle-logs # Tail logs (follow mode)
|
||||
# eagle-logs -n 100 # Show last 100 lines and follow
|
||||
# eagle-logs --no-follow -n 50 # Show last 50 lines without following
|
||||
#
|
||||
# To create an alias, add to ~/.bashrc:
|
||||
# alias eagle-logs='/opt/eagle0/scripts/eagle-logs.sh'
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="${APP_DIR:-/opt/eagle0}"
|
||||
ACTIVE_FILE="${APP_DIR}/.active-instance"
|
||||
|
||||
# Read active instance from file, with fallback
|
||||
if [ -f "$ACTIVE_FILE" ]; then
|
||||
ACTIVE=$(cat "$ACTIVE_FILE")
|
||||
else
|
||||
# Fallback: check which container is actually running
|
||||
if docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null | grep -q true; then
|
||||
ACTIVE="eagle-blue"
|
||||
elif docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null | grep -q true; then
|
||||
ACTIVE="eagle-green"
|
||||
else
|
||||
ACTIVE=""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$ACTIVE" ]; then
|
||||
echo "Error: No active Eagle instance found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Default to follow mode if no args provided
|
||||
if [ $# -eq 0 ]; then
|
||||
exec docker logs -f "$ACTIVE"
|
||||
else
|
||||
exec docker logs "$@" "$ACTIVE"
|
||||
fi
|
||||
@@ -37,4 +37,7 @@ sysinfo.txt
|
||||
*.apk
|
||||
*.unitypackage
|
||||
|
||||
mono_crash.*
|
||||
mono_crash.*
|
||||
|
||||
# Local server data
|
||||
ServerData/
|
||||
File diff suppressed because it is too large
Load Diff
+7
-7
@@ -15,7 +15,7 @@ MonoBehaviour:
|
||||
m_DefaultGroup: 4458439bbfafa478b905417eff3c5741
|
||||
m_currentHash:
|
||||
serializedVersion: 2
|
||||
Hash: 00000000000000000000000000000000
|
||||
Hash: 0e8900f37a8b7b1c88049de57e371339
|
||||
m_OptimizeCatalogSize: 0
|
||||
m_BuildRemoteCatalog: 1
|
||||
m_CatalogRequestsTimeout: 0
|
||||
@@ -24,11 +24,11 @@ MonoBehaviour:
|
||||
m_InternalBundleIdMode: 1
|
||||
m_AssetLoadMode: 0
|
||||
m_BundledAssetProviderType:
|
||||
m_AssemblyName:
|
||||
m_ClassName:
|
||||
m_AssemblyName: Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
|
||||
m_ClassName: UnityEngine.ResourceManagement.ResourceProviders.BundledAssetProvider
|
||||
m_AssetBundleProviderType:
|
||||
m_AssemblyName:
|
||||
m_ClassName:
|
||||
m_AssemblyName: Unity.ResourceManager, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
|
||||
m_ClassName: UnityEngine.ResourceManagement.ResourceProviders.AssetBundleProvider
|
||||
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: 0ebe438313ec24b6894ce84cf4c89605
|
||||
guid: 568267bd7b6fa4581adaa40faa984594
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
BIN
Binary file not shown.
+7
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a1b5efbbd8a2c4df59c9f7d542172b18
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -67,6 +67,18 @@ 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.
|
||||
@@ -187,6 +199,23 @@ 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,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using common;
|
||||
using Grpc.Core;
|
||||
using Net.Eagle0.Eagle.Api.Auth;
|
||||
using UnityEngine;
|
||||
|
||||
@@ -20,6 +21,10 @@ 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;
|
||||
@@ -45,6 +50,175 @@ 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>
|
||||
@@ -64,9 +238,27 @@ 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; }
|
||||
}
|
||||
@@ -80,7 +272,9 @@ namespace Auth {
|
||||
|
||||
/// <summary>
|
||||
/// Start OAuth login with specified provider.
|
||||
/// Opens system browser for user consent, then polls for completion.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<CheckOAuthStatusResponse> LoginAsync(OAuthProvider provider) {
|
||||
EnsureConfigured();
|
||||
@@ -90,41 +284,23 @@ 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: {authUrl}");
|
||||
Debug.Log($"[OAuthManager] Opening browser for OAuth: {authUrl}");
|
||||
Application.OpenURL(authUrl);
|
||||
|
||||
// Poll for OAuth completion (server handles the callback)
|
||||
var response = await _authClient.PollForOAuthCompletionAsync(state);
|
||||
// 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();
|
||||
|
||||
// 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;
|
||||
// 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;
|
||||
} catch (Exception ex) {
|
||||
// Bring game back to foreground even on failure
|
||||
WindowFocusManager.BringToForeground();
|
||||
|
||||
Debug.LogError($"[OAuthManager] Login failed: {ex.Message}");
|
||||
Debug.LogWarning($"[OAuthManager] Login failed: {ex.Message}");
|
||||
OnLoginFailed?.Invoke(ex.Message);
|
||||
throw;
|
||||
}
|
||||
@@ -184,6 +360,13 @@ 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();
|
||||
@@ -274,6 +457,13 @@ 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.");
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/// <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 = "";
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9f6bcf450bd4543d39dbba8e09b7605c
|
||||
+14
-14
@@ -94,8 +94,8 @@ MonoBehaviour:
|
||||
m_faceColor:
|
||||
serializedVersion: 2
|
||||
rgba: 4294967295
|
||||
m_fontSize: 30
|
||||
m_fontSizeBase: 30
|
||||
m_fontSize: 18
|
||||
m_fontSizeBase: 18
|
||||
m_fontWeight: 400
|
||||
m_enableAutoSizing: 0
|
||||
m_fontSizeMin: 18
|
||||
@@ -152,11 +152,11 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: 450
|
||||
m_MinWidth: 240
|
||||
m_MinHeight: 60
|
||||
m_PreferredWidth: 450
|
||||
m_PreferredWidth: 240
|
||||
m_PreferredHeight: 60
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleWidth: 0
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
--- !u!1 &3769343953609136136
|
||||
@@ -253,8 +253,8 @@ MonoBehaviour:
|
||||
m_faceColor:
|
||||
serializedVersion: 2
|
||||
rgba: 4294967295
|
||||
m_fontSize: 36
|
||||
m_fontSizeBase: 36
|
||||
m_fontSize: 18
|
||||
m_fontSizeBase: 18
|
||||
m_fontWeight: 400
|
||||
m_enableAutoSizing: 0
|
||||
m_fontSizeMin: 18
|
||||
@@ -311,9 +311,9 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: 180
|
||||
m_MinWidth: 200
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: 180
|
||||
m_PreferredWidth: 200
|
||||
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: -1
|
||||
m_FlexibleWidth: 0
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
--- !u!1 &4995675505853930853
|
||||
@@ -636,8 +636,8 @@ MonoBehaviour:
|
||||
m_faceColor:
|
||||
serializedVersion: 2
|
||||
rgba: 4294967295
|
||||
m_fontSize: 36
|
||||
m_fontSizeBase: 36
|
||||
m_fontSize: 18
|
||||
m_fontSizeBase: 18
|
||||
m_fontWeight: 400
|
||||
m_enableAutoSizing: 0
|
||||
m_fontSizeMin: 18
|
||||
@@ -1285,9 +1285,9 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: 700
|
||||
m_MinWidth: 300
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: 700
|
||||
m_PreferredWidth: 300
|
||||
m_PreferredHeight: -1
|
||||
m_FlexibleWidth: 1
|
||||
m_FlexibleHeight: -1
|
||||
|
||||
+6
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using eagle;
|
||||
using Eagle0.Tutorial;
|
||||
using Net.Eagle0.Eagle.Api;
|
||||
@@ -73,6 +74,11 @@ 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;
|
||||
}
|
||||
|
||||
+56
-22
@@ -136,7 +136,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
|
||||
public Canvas connectionCanvas;
|
||||
public Canvas eagleCanvas;
|
||||
public Canvas shardokCanvas;
|
||||
public GameObject shardokContainer;
|
||||
public CustomBattleHandler customBattleHandler;
|
||||
|
||||
List<GameInfo> fetchedRunningGames;
|
||||
@@ -150,8 +150,10 @@ 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();
|
||||
|
||||
public ClientPregeneratedText clientPregeneratedText;
|
||||
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;
|
||||
|
||||
const String BaseDomain = "eagle0.net";
|
||||
const String EnvironmentKey = "environmentKey";
|
||||
@@ -192,10 +194,6 @@ 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;
|
||||
@@ -206,7 +204,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
|
||||
connectionCanvas.gameObject.SetActive(true);
|
||||
eagleCanvas.gameObject.SetActive(false);
|
||||
shardokCanvas.gameObject.SetActive(false);
|
||||
shardokContainer.SetActive(false);
|
||||
|
||||
connectionCanvas.enabled = true;
|
||||
connectionPanel.gameObject.SetActive(true);
|
||||
@@ -301,6 +299,24 @@ 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 = ""; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,13 +494,11 @@ 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) {
|
||||
// Session restored, connect to lobby
|
||||
ConnectWithOAuth();
|
||||
} else if (oauthStatusText != null) {
|
||||
oauthStatusText.text = "";
|
||||
}
|
||||
if (!restored && oauthStatusText != null) { oauthStatusText.text = ""; }
|
||||
}
|
||||
|
||||
private async void OnOAuthLoginClicked(OAuthProvider provider) {
|
||||
@@ -499,7 +513,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
await OAuthManager.Instance.LoginAsync(provider);
|
||||
// OnLoginSuccess or OnNewUserNeedsDisplayName will be called
|
||||
} catch (Exception ex) {
|
||||
Debug.LogError($"OAuth login failed: {ex.Message}");
|
||||
Debug.LogWarning($"OAuth login failed: {ex.Message}");
|
||||
if (oauthStatusText != null) { oauthStatusText.text = $"Login failed: {ex.Message}"; }
|
||||
}
|
||||
}
|
||||
@@ -507,6 +521,13 @@ 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}");
|
||||
}
|
||||
ConnectWithOAuth();
|
||||
});
|
||||
}
|
||||
@@ -603,7 +624,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
|
||||
public void CancelCustomBattle() {
|
||||
customBattlePanel.SetActive(false);
|
||||
shardokCanvas.gameObject.SetActive(false);
|
||||
shardokContainer.SetActive(false);
|
||||
connectionCanvas.gameObject.SetActive(true);
|
||||
gameSelectionPanel.SetActive(true);
|
||||
StartListeningForLobbyUpdates();
|
||||
@@ -820,6 +841,19 @@ 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;
|
||||
@@ -860,13 +894,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) {
|
||||
|
||||
+427
-452
File diff suppressed because it is too large
Load Diff
+5
@@ -72,6 +72,11 @@ 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);
|
||||
|
||||
+6
-3
@@ -22,7 +22,7 @@ using VictoryCondition = Net.Eagle0.Common.VictoryCondition;
|
||||
public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
|
||||
public GameObject yourArmiesArea;
|
||||
public GameObject aiArmiesArea;
|
||||
public Canvas shardokCanvas;
|
||||
public GameObject shardokContainer;
|
||||
|
||||
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" } });
|
||||
|
||||
shardokCanvas.gameObject.SetActive(true);
|
||||
shardokCanvas.GetComponent<ShardokGameController>().SetUpGame(_shardokModel);
|
||||
shardokContainer.SetActive(true);
|
||||
shardokContainer.GetComponent<ShardokGameController>().SetUpGame(_shardokModel);
|
||||
|
||||
GameId = newGameResponse.EagleGameId;
|
||||
Register();
|
||||
@@ -438,6 +438,9 @@ 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() {
|
||||
|
||||
+8
-2
@@ -30,8 +30,14 @@ public class RunningGameItem : MonoBehaviour {
|
||||
this.gameId = gameId;
|
||||
|
||||
gameIdField.text = string.Format("{0:X}", gameId);
|
||||
var textEntry = ClientTextProvider.Provider.GetTextEntry(leader.NameTextId);
|
||||
var leaderName = textEntry != null ? textEntry.Text : "Hero";
|
||||
// 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";
|
||||
}
|
||||
leaderField.text = string.Format(
|
||||
"{0} ({1})",
|
||||
leaderName,
|
||||
|
||||
+40
-98
@@ -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,24 +101,27 @@ MonoBehaviour:
|
||||
m_fontSizeMin: 18
|
||||
m_fontSizeMax: 72
|
||||
m_fontStyle: 0
|
||||
m_HorizontalAlignment: 2
|
||||
m_HorizontalAlignment: 1
|
||||
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_enableWordWrapping: 1
|
||||
m_TextWrappingMode: 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
|
||||
@@ -149,9 +152,9 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: 700
|
||||
m_MinWidth: 300
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: 700
|
||||
m_PreferredWidth: -1
|
||||
m_PreferredHeight: -1
|
||||
m_FlexibleWidth: 1
|
||||
m_FlexibleHeight: -1
|
||||
@@ -185,9 +188,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}
|
||||
@@ -261,20 +264,23 @@ 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_enableWordWrapping: 1
|
||||
m_TextWrappingMode: 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
|
||||
@@ -305,11 +311,11 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: 180
|
||||
m_MinWidth: 300
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: 180
|
||||
m_PreferredWidth: 300
|
||||
m_PreferredHeight: -1
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleWidth: 0
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
--- !u!1 &1722990544386167487
|
||||
@@ -322,8 +328,6 @@ GameObject:
|
||||
m_Component:
|
||||
- component: {fileID: 5405233146667072049}
|
||||
- component: {fileID: 9089165643261525487}
|
||||
- component: {fileID: 3309908850111668078}
|
||||
- component: {fileID: 7352302768073002882}
|
||||
- component: {fileID: 28101565677326836}
|
||||
- component: {fileID: 4394564974352927053}
|
||||
- component: {fileID: 1180739972031081654}
|
||||
@@ -344,10 +348,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}
|
||||
@@ -362,74 +366,6 @@ 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
|
||||
@@ -474,7 +410,7 @@ MonoBehaviour:
|
||||
m_OnClick:
|
||||
m_PersistentCalls:
|
||||
m_Calls:
|
||||
- m_Target: {fileID: 0}
|
||||
- m_Target: {fileID: 7494010136792472269}
|
||||
m_TargetAssemblyTypeName:
|
||||
m_MethodName: JoinClicked
|
||||
m_Mode: 1
|
||||
@@ -529,11 +465,11 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: 200
|
||||
m_MinWidth: 120
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: 200
|
||||
m_PreferredWidth: 120
|
||||
m_PreferredHeight: -1
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleWidth: 0
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
--- !u!1 &3410015936189372563
|
||||
@@ -565,9 +501,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}
|
||||
@@ -630,8 +566,8 @@ MonoBehaviour:
|
||||
m_faceColor:
|
||||
serializedVersion: 2
|
||||
rgba: 4294967295
|
||||
m_fontSize: 30
|
||||
m_fontSizeBase: 30
|
||||
m_fontSize: 18
|
||||
m_fontSizeBase: 18
|
||||
m_fontWeight: 400
|
||||
m_enableAutoSizing: 0
|
||||
m_fontSizeMin: 18
|
||||
@@ -641,20 +577,23 @@ 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_enableWordWrapping: 1
|
||||
m_TextWrappingMode: 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
|
||||
@@ -685,11 +624,11 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: 450
|
||||
m_MinWidth: 240
|
||||
m_MinHeight: 60
|
||||
m_PreferredWidth: 450
|
||||
m_PreferredWidth: 240
|
||||
m_PreferredHeight: 60
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleWidth: 0
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
--- !u!1 &7494010136792472269
|
||||
@@ -722,13 +661,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}
|
||||
@@ -843,9 +782,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}
|
||||
@@ -908,31 +847,34 @@ MonoBehaviour:
|
||||
m_faceColor:
|
||||
serializedVersion: 2
|
||||
rgba: 4294967295
|
||||
m_fontSize: 24
|
||||
m_fontSizeBase: 24
|
||||
m_fontSize: 18
|
||||
m_fontSizeBase: 18
|
||||
m_fontWeight: 400
|
||||
m_enableAutoSizing: 0
|
||||
m_fontSizeMin: 18
|
||||
m_fontSizeMax: 72
|
||||
m_fontStyle: 0
|
||||
m_HorizontalAlignment: 2
|
||||
m_HorizontalAlignment: 1
|
||||
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_enableWordWrapping: 1
|
||||
m_TextWrappingMode: 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
|
||||
|
||||
+8
-2
@@ -16,8 +16,14 @@ public class WaitingGameItem : MonoBehaviour {
|
||||
gameIdField.text = string.Format("{0:X}", gameId);
|
||||
playerCountField.text = playersString;
|
||||
|
||||
var textEntry = ClientTextProvider.Provider.GetTextEntry(leader.NameTextId);
|
||||
var leaderName = textEntry != null ? textEntry.Text : "Hero";
|
||||
// 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";
|
||||
}
|
||||
leaderField.text = string.Format(
|
||||
"{0} ({1})",
|
||||
leaderName,
|
||||
|
||||
+7
-1
@@ -44,7 +44,13 @@ namespace eagle {
|
||||
var wasEmpty = _entries.Count == 0;
|
||||
_entries = value != null ? value.ToList() : new List<ChronicleEntry>();
|
||||
|
||||
if (_entries.Count == 0) return;
|
||||
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;
|
||||
}
|
||||
|
||||
// Jump to the last entry when first populating, or if not currently viewing
|
||||
if (wasEmpty || !gameObject.activeSelf) {
|
||||
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
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
@@ -1,3 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 347714dfb2a64d76ac4b4d87060e6159
|
||||
timeCreated: 1717958518
|
||||
@@ -104,10 +104,6 @@ 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;
|
||||
|
||||
@@ -58,6 +58,8 @@ namespace eagle {
|
||||
return;
|
||||
}
|
||||
|
||||
// Pass layout mode to selector before setting up UI
|
||||
Selector.CurrentLayoutMode = mainController.CurrentLayoutMode;
|
||||
Selector.UpdateAvailableCommand(Model, AvailableCommand);
|
||||
headerLabel.text = Selector.HeaderString;
|
||||
} catch (Exception e) { errorHandler.Add(e); }
|
||||
|
||||
+3
-2
@@ -47,8 +47,9 @@ namespace eagle {
|
||||
AlmsCommand.AvailableHeroIds.Select(hid => _model.Heroes[hid]).ToList();
|
||||
|
||||
foodSlider.maxValue = AvailableFoodAmount;
|
||||
foodSlider.value = 0;
|
||||
foodGivenLabel.text = "0";
|
||||
var defaultAmount = Math.Min(1000, AvailableFoodAmount);
|
||||
foodSlider.value = defaultAmount;
|
||||
foodGivenLabel.text = defaultAmount.ToString();
|
||||
}
|
||||
|
||||
public void SelectedProvinceChanged(int newValue) {
|
||||
|
||||
+13
@@ -14,6 +14,19 @@ 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;
|
||||
|
||||
public abstract AvailableCommand.SealedValueOneofCase CommandType { get; }
|
||||
public virtual bool IsAppropriateFor(AvailableCommand cmd) => cmd.SealedValueCase
|
||||
== CommandType;
|
||||
|
||||
+9
-1
@@ -5,7 +5,7 @@ using Net.Eagle0.Eagle.Api;
|
||||
using Net.Eagle0.Eagle.Api.Command.Util;
|
||||
using Net.Eagle0.Eagle.Common;
|
||||
using TMPro;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace eagle {
|
||||
using BattalionId = Int32;
|
||||
@@ -25,6 +25,10 @@ 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";
|
||||
@@ -128,6 +132,10 @@ namespace eagle {
|
||||
}
|
||||
|
||||
protected override void SetUpUI() {
|
||||
// Hide hero/battalion columns in narrow mode - users select via side panels
|
||||
if (heroesColumn != null) heroesColumn.SetActive(!IsNarrowLayout);
|
||||
if (battalionsColumn != null) battalionsColumn.SetActive(!IsNarrowLayout);
|
||||
|
||||
if (DefendCommand.AvailableFleeProvinceIds.Count > 0) {
|
||||
var availableRallyPointProvinceNames =
|
||||
DefendCommand.AvailableFleeProvinceIds
|
||||
|
||||
+61
-1
@@ -1,8 +1,10 @@
|
||||
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;
|
||||
@@ -139,7 +141,7 @@ namespace eagle {
|
||||
.Select(name => new TMP_Dropdown.OptionData(name))
|
||||
.ToList();
|
||||
|
||||
MessageText.text = "";
|
||||
MessageText.text = GetQuestMessagesForPrisoner(SelectedHero.Id);
|
||||
|
||||
// The order here is important: the last available toggle in this list
|
||||
// will be on by default
|
||||
@@ -165,6 +167,64 @@ namespace eagle {
|
||||
PrisonerManagementOption.SealedValueOneofCase.Release);
|
||||
}
|
||||
|
||||
private string GetQuestMessagesForPrisoner(HeroId prisonerId) {
|
||||
var questMessages = new List<string>();
|
||||
|
||||
foreach (var province in _model.Provinces.Values) {
|
||||
if (province.FullInfo?.UnaffiliatedHeroes == null) continue;
|
||||
|
||||
foreach (var uh in province.FullInfo.UnaffiliatedHeroes) {
|
||||
if (uh.RecruitmentInfo?.Status != RecruitmentStatus.HasQuest) continue;
|
||||
var quest = uh.RecruitmentInfo.Quest;
|
||||
if (quest?.Details == null) continue;
|
||||
|
||||
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) : "";
|
||||
}
|
||||
|
||||
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 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 bool ConditionallyEnable(
|
||||
Toggle toggle,
|
||||
PrisonerManagementOption.SealedValueOneofCase option) {
|
||||
|
||||
+13
-1
@@ -7,6 +7,7 @@ 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 {
|
||||
@@ -38,6 +39,10 @@ namespace eagle {
|
||||
public EventBasedUnitSelector unitSelector;
|
||||
public TMP_Dropdown fromDropdown;
|
||||
public TMP_Dropdown toDropdown;
|
||||
|
||||
// Columns to hide in narrow layout mode (users select via side panels instead)
|
||||
public GameObject heroesColumn;
|
||||
public GameObject battalionsColumn;
|
||||
public Slider goldSlider;
|
||||
public Slider foodSlider;
|
||||
public TMP_Text goldTakenLabel;
|
||||
@@ -121,9 +126,12 @@ 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;
|
||||
_sortedAvailableDestinations[idx].RequiresRiverCrossing &&
|
||||
DestinationRulingFactionId != _playerId;
|
||||
|
||||
public override bool WarnOnCommitButton =>
|
||||
WouldAbandonProvince || NotEnoughFood || MarchingOnTrucePartner ||
|
||||
@@ -294,6 +302,10 @@ namespace eagle {
|
||||
}
|
||||
|
||||
protected override void SetUpUI() {
|
||||
// Hide hero/battalion columns in narrow mode - users select via side panels
|
||||
if (heroesColumn != null) heroesColumn.SetActive(!IsNarrowLayout);
|
||||
if (battalionsColumn != null) battalionsColumn.SetActive(!IsNarrowLayout);
|
||||
|
||||
_sortedOneProvinceCommands =
|
||||
MarchAvailableCommand.OneProvinceCommands
|
||||
.OrderBy(cmd => _model.Provinces[cmd.OriginProvinceId].Name)
|
||||
|
||||
+36
-3
@@ -20,9 +20,42 @@ namespace eagle {
|
||||
public override string HeaderString =>
|
||||
$"Suppress {DisplayNames.Capitalized(BeastTypeName)}";
|
||||
public override string CommitButtonString => "Commit Suppression";
|
||||
public override bool WarnOnCommitButton => !SelectedBattalionId.HasValue;
|
||||
public override string CommitWarningText =>
|
||||
"You aren't sending a battalion. Your hero may be killed.";
|
||||
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 List<HeroId> TargetedHeroIds => new List<HeroId> { SelectedHeroId };
|
||||
public override bool HeroIsTargetable(HeroId heroId) =>
|
||||
|
||||
+9
@@ -15,6 +15,7 @@ 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;
|
||||
@@ -40,6 +41,14 @@ namespace eagle {
|
||||
buyPrice.text = $"{TradeCommand.FoodBuyPrice:0.##}";
|
||||
sellPrice.text = $"{TradeCommand.FoodSellPrice:0.##}";
|
||||
|
||||
// Position the starting position indicator
|
||||
if (startingPositionIndicator != null && MaxFood > 0) {
|
||||
float normalizedStart = (float)TradeCommand.FoodAvailable / MaxFood;
|
||||
startingPositionIndicator.anchorMin = new Vector2(normalizedStart, 0);
|
||||
startingPositionIndicator.anchorMax = new Vector2(normalizedStart, 1);
|
||||
startingPositionIndicator.anchoredPosition = Vector2.zero;
|
||||
}
|
||||
|
||||
SetTextFields();
|
||||
}
|
||||
|
||||
|
||||
+26
-8
@@ -20,6 +20,13 @@ 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;
|
||||
@@ -32,17 +39,28 @@ namespace eagle {
|
||||
listeners = new List<SingleEntryListener>();
|
||||
for (int i = 0; i < _availableHeroes.Count; i++) {
|
||||
HeroView hero = _availableHeroes[i];
|
||||
var listener = new SingleEntryListener(
|
||||
hero.NameTextId,
|
||||
"",
|
||||
var suffix =
|
||||
showCondition
|
||||
? $" ({GUIUtils.ConditionString(ConditionAccessor(hero), ConditionName)})"
|
||||
: "",
|
||||
i,
|
||||
SetGeneratedText);
|
||||
: "";
|
||||
|
||||
listeners.Add(listener);
|
||||
ClientTextProvider.Provider.AddListener(listener);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
SetHeadshot();
|
||||
|
||||
@@ -155,6 +155,8 @@ 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"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,6 +158,11 @@ namespace eagle {
|
||||
case SealedValueOneofCase.ReturnPrisonerQuest: return "Return Prisoner";
|
||||
case SealedValueOneofCase.ExilePrisonerQuest: return "Exile Prisoner";
|
||||
case SealedValueOneofCase.ExecutePrisonerQuest: return "Execute Prisoner";
|
||||
case SealedValueOneofCase.RescueImprisonedLeaderQuest: return "Rescue Hero";
|
||||
case SealedValueOneofCase.ExpandToProvincesQuest: return "Expand to Provinces";
|
||||
case SealedValueOneofCase.TotalDevelopmentQuest: return "Total Development";
|
||||
case SealedValueOneofCase.SuppressRiotByForceQuest: return "Suppress Riot";
|
||||
case SealedValueOneofCase.FightBeastsAloneQuest: return "Fight Beasts Alone";
|
||||
}
|
||||
|
||||
throw new ArgumentException($"unknown quest {quest}");
|
||||
|
||||
+131
-60
@@ -19,6 +19,14 @@ namespace eagle {
|
||||
using FactionId = Int32;
|
||||
using ProvinceId = Int32;
|
||||
|
||||
/// <summary>
|
||||
/// Layout mode based on aspect ratio:
|
||||
/// - Wide: Ultra-wide screens (ratio > 2.0), factions/armies column on right
|
||||
/// - Medium: Standard widescreen (ratio >= 1.35), default layout
|
||||
/// - Narrow: iPad-like 4:3 (ratio < 1.35), compact command panels
|
||||
/// </summary>
|
||||
public enum LayoutMode { Wide, Medium, Narrow }
|
||||
|
||||
public class EagleGameController : MonoBehaviour {
|
||||
public SoundManager soundManager;
|
||||
|
||||
@@ -26,21 +34,26 @@ namespace eagle {
|
||||
public TextMeshProUGUI connectionStatusLabel;
|
||||
private bool _connectionStatusUIInitialized = false;
|
||||
|
||||
public GameObject alwaysOnLeftColumn;
|
||||
public GameObject factionsAndMovingArmiesRow;
|
||||
public GameObject wideScreenRightColumn;
|
||||
public GameObject wideScreenBottomRow;
|
||||
|
||||
public ProvinceInfoPanelController provinceInfoPanelController;
|
||||
public FreeHeroesTableController freeHeroesTableController;
|
||||
public FactionsTableController factionsTableController;
|
||||
public MovingArmiesTableController movingArmiesTableController;
|
||||
|
||||
// Containers for factions & moving armies panels (enable/disable these)
|
||||
public GameObject factionsAndMovingArmiesNarrow; // Row beneath Free Heroes
|
||||
public GameObject factionsAndMovingArmiesWide; // Column to right of Province Info
|
||||
|
||||
// Controller references for updating data (panels inside containers)
|
||||
public FactionsTableController factionsTableControllerNarrow;
|
||||
public MovingArmiesTableController movingArmiesTableControllerNarrow;
|
||||
public FactionsTableController factionsTableControllerWide;
|
||||
public MovingArmiesTableController movingArmiesTableControllerWide;
|
||||
|
||||
public HeroesAndBattalionsPanelController heroesAndBattalionsPanelController;
|
||||
public MapController mapController;
|
||||
public CommandButtonPanelController commandButtonPanelController;
|
||||
public DominionPanelController hideableDominionPanelController;
|
||||
public DominionPanelController wideDominionPanelController;
|
||||
private DominionPanelController _dominionPanelController;
|
||||
|
||||
// Narrow: toggleable above Province Info; Wide: always-on below Free Heroes
|
||||
public DominionPanelController dominionPanelControllerNarrow;
|
||||
public DominionPanelController dominionPanelControllerWide;
|
||||
public ChronicleCanvasController chronicleCanvasController;
|
||||
|
||||
public ProvinceId? SelectedProvince => mapController.SelectedProvinceId;
|
||||
@@ -48,6 +61,12 @@ namespace eagle {
|
||||
public ErrorHandler errorHandler;
|
||||
public NotificationPanel notificationPanel;
|
||||
public GameObject popupCanvas;
|
||||
public GameObject wideSpacer;
|
||||
|
||||
// Widescreen layout: match HeroesAndBattalions width to Popup Canvas spacers
|
||||
public LayoutElement heroesAndBattalionsLayoutElement;
|
||||
public LayoutElement popupCanvasLeftSpacer;
|
||||
public LayoutElement popupCanvasRightSpacer; // Same object as wideSpacer
|
||||
|
||||
public Button showChronicleButton;
|
||||
public Button commitCommandButton;
|
||||
@@ -70,7 +89,22 @@ namespace eagle {
|
||||
public CommandWarningPanelController commandWarningPanelController;
|
||||
|
||||
private double _aspectRatio;
|
||||
private bool IsWideScreen => _aspectRatio > 2.0;
|
||||
private const double WideScreenThreshold = 2.0;
|
||||
private const double NarrowScreenThreshold = 1.35;
|
||||
|
||||
private bool IsWideScreen => _aspectRatio > WideScreenThreshold;
|
||||
private bool IsNarrowScreen => _aspectRatio < NarrowScreenThreshold;
|
||||
|
||||
/// <summary>
|
||||
/// Current layout mode based on aspect ratio. Accessible to CommandSelectors.
|
||||
/// </summary>
|
||||
public LayoutMode CurrentLayoutMode {
|
||||
get {
|
||||
if (IsWideScreen) return LayoutMode.Wide;
|
||||
if (IsNarrowScreen) return LayoutMode.Narrow;
|
||||
return LayoutMode.Medium;
|
||||
}
|
||||
}
|
||||
|
||||
private int _lastWidth;
|
||||
private int _lastHeight;
|
||||
@@ -81,7 +115,7 @@ namespace eagle {
|
||||
private DynamicFactionTextUpdater factionTextUpdater = new DynamicFactionTextUpdater();
|
||||
private IGameModel _newModel = null;
|
||||
|
||||
public Canvas shardokCanvas;
|
||||
public GameObject shardokContainer;
|
||||
|
||||
private KeyValuePair<ProvinceId, OneProvinceAvailableCommands>? NextActiveProvinceKeyPair =>
|
||||
Model == null || Model.AvailableCommandsByProvince.Count == 0
|
||||
@@ -94,8 +128,7 @@ namespace eagle {
|
||||
mapController.targetedProvincesChangedCallback = MapControllerChangedTarget;
|
||||
mapController.hoveredProvinceChangedCallback = MapControllerChangedHover;
|
||||
popupCanvas.SetActive(true);
|
||||
_dominionPanelController = hideableDominionPanelController;
|
||||
_dominionPanelController.gameObject.SetActive(false);
|
||||
dominionPanelControllerNarrow.gameObject.SetActive(false);
|
||||
showChronicleButton.gameObject.SetActive(false);
|
||||
|
||||
errorHandler.gameObject.SetActive(true);
|
||||
@@ -125,31 +158,35 @@ namespace eagle {
|
||||
_aspectRatio = (float)newWidth / (float)newHeight;
|
||||
|
||||
if (IsWideScreen) {
|
||||
factionsTableController.gameObject.transform.SetParent(
|
||||
wideScreenRightColumn.transform);
|
||||
factionsTableController.GetComponent<LayoutElement>().flexibleWidth = 1;
|
||||
movingArmiesTableController.gameObject.transform.SetParent(
|
||||
wideScreenRightColumn.transform);
|
||||
movingArmiesTableController.GetComponent<LayoutElement>().flexibleWidth = 1;
|
||||
wideDominionPanelController.gameObject.SetActive(true);
|
||||
hideableDominionPanelController.gameObject.SetActive(false);
|
||||
_dominionPanelController = wideDominionPanelController;
|
||||
_dominionPanelController.Model = Model;
|
||||
// Show wide layout containers, hide narrow layout containers
|
||||
factionsAndMovingArmiesWide.SetActive(true);
|
||||
factionsAndMovingArmiesNarrow.SetActive(false);
|
||||
dominionPanelControllerWide.gameObject.SetActive(true);
|
||||
dominionPanelControllerNarrow.gameObject.SetActive(false);
|
||||
if (wideSpacer != null) wideSpacer.SetActive(true);
|
||||
|
||||
factionsAndMovingArmiesRow.SetActive(false);
|
||||
// Match HeroesAndBattalions width to Popup Canvas spacers
|
||||
if (heroesAndBattalionsLayoutElement != null && popupCanvasLeftSpacer != null &&
|
||||
popupCanvasRightSpacer != null) {
|
||||
heroesAndBattalionsLayoutElement.preferredWidth =
|
||||
popupCanvasLeftSpacer.preferredWidth +
|
||||
popupCanvasRightSpacer.preferredWidth;
|
||||
}
|
||||
|
||||
provinceInfoPanelController.WideScreen = true;
|
||||
} else {
|
||||
factionsTableController.gameObject.transform.SetParent(
|
||||
factionsAndMovingArmiesRow.transform);
|
||||
factionsTableController.GetComponent<LayoutElement>().flexibleWidth = 0;
|
||||
movingArmiesTableController.gameObject.transform.SetParent(
|
||||
factionsAndMovingArmiesRow.transform);
|
||||
movingArmiesTableController.GetComponent<LayoutElement>().flexibleWidth = 0;
|
||||
wideDominionPanelController.gameObject.SetActive(false);
|
||||
_dominionPanelController = hideableDominionPanelController;
|
||||
_dominionPanelController.Model = Model;
|
||||
factionsAndMovingArmiesRow.SetActive(true);
|
||||
// Show narrow layout containers, hide wide layout containers
|
||||
factionsAndMovingArmiesWide.SetActive(false);
|
||||
factionsAndMovingArmiesNarrow.SetActive(true);
|
||||
dominionPanelControllerWide.gameObject.SetActive(false);
|
||||
// Narrow dominion panel visibility controlled by user toggle, don't change here
|
||||
if (wideSpacer != null) wideSpacer.SetActive(false);
|
||||
|
||||
// Match HeroesAndBattalions width to left spacer only
|
||||
if (heroesAndBattalionsLayoutElement != null && popupCanvasLeftSpacer != null) {
|
||||
heroesAndBattalionsLayoutElement.preferredWidth =
|
||||
popupCanvasLeftSpacer.preferredWidth;
|
||||
}
|
||||
|
||||
provinceInfoPanelController.WideScreen = false;
|
||||
}
|
||||
@@ -176,16 +213,21 @@ namespace eagle {
|
||||
if (_commandPanelController.Selector != null) {
|
||||
_commandPanelController.Selector.TargetedProvinces = newTarget;
|
||||
freeHeroesTableController.UpdateTables();
|
||||
movingArmiesTableController.UpdateTables();
|
||||
factionsTableController.UpdateTables();
|
||||
movingArmiesTableControllerNarrow.UpdateTables();
|
||||
movingArmiesTableControllerWide.UpdateTables();
|
||||
factionsTableControllerNarrow.UpdateTables();
|
||||
factionsTableControllerWide.UpdateTables();
|
||||
heroesAndBattalionsPanelController.UpdateTables();
|
||||
}
|
||||
}
|
||||
|
||||
void MapControllerChangedHover(ProvinceId? newHover) {
|
||||
_dominionPanelController.ProvinceHovered(newHover);
|
||||
movingArmiesTableController.ProvinceHovered(newHover);
|
||||
factionsTableController.ProvinceHovered(newHover);
|
||||
dominionPanelControllerNarrow.ProvinceHovered(newHover);
|
||||
dominionPanelControllerWide.ProvinceHovered(newHover);
|
||||
movingArmiesTableControllerNarrow.ProvinceHovered(newHover);
|
||||
movingArmiesTableControllerWide.ProvinceHovered(newHover);
|
||||
factionsTableControllerNarrow.ProvinceHovered(newHover);
|
||||
factionsTableControllerWide.ProvinceHovered(newHover);
|
||||
}
|
||||
|
||||
void Update() {
|
||||
@@ -224,7 +266,8 @@ namespace eagle {
|
||||
public void ReloadFactionDisplaySetting() {
|
||||
SetHeaderString();
|
||||
mapController.ReloadFactionDisplaySetting();
|
||||
factionsTableController.UpdateTables();
|
||||
factionsTableControllerNarrow.UpdateTables();
|
||||
factionsTableControllerWide.UpdateTables();
|
||||
_commandPanelController.RefreshUI();
|
||||
}
|
||||
|
||||
@@ -321,8 +364,10 @@ namespace eagle {
|
||||
|
||||
_commandPanelController.SetAvailableCommandAndSelector(cmd, selector);
|
||||
mapController.ProvincesWithCommands = new List<ProvinceId>();
|
||||
movingArmiesTableController.UpdateTables();
|
||||
factionsTableController.UpdateTables();
|
||||
movingArmiesTableControllerNarrow.UpdateTables();
|
||||
movingArmiesTableControllerWide.UpdateTables();
|
||||
factionsTableControllerNarrow.UpdateTables();
|
||||
factionsTableControllerWide.UpdateTables();
|
||||
heroesAndBattalionsPanelController.UpdateTables();
|
||||
freeHeroesTableController.UpdateUnaffiliatedHeroSelections();
|
||||
}
|
||||
@@ -352,21 +397,26 @@ namespace eagle {
|
||||
var currentProvince = Model.Provinces[pid.Value];
|
||||
provinceInfoPanelController.Province = currentProvince;
|
||||
freeHeroesTableController.CurrentProvince = currentProvince;
|
||||
movingArmiesTableController.CurrentProvince = currentProvince;
|
||||
factionsTableController.CurrentProvince = currentProvince;
|
||||
movingArmiesTableControllerNarrow.CurrentProvince = currentProvince;
|
||||
movingArmiesTableControllerWide.CurrentProvince = currentProvince;
|
||||
factionsTableControllerNarrow.CurrentProvince = currentProvince;
|
||||
factionsTableControllerWide.CurrentProvince = currentProvince;
|
||||
heroesAndBattalionsPanelController.CurrentProvince = currentProvince;
|
||||
} else {
|
||||
SelectRecommendedCommand(null);
|
||||
provinceInfoPanelController.Province = null;
|
||||
freeHeroesTableController.CurrentProvince = null;
|
||||
movingArmiesTableController.CurrentProvince = null;
|
||||
factionsTableController.CurrentProvince = null;
|
||||
movingArmiesTableControllerNarrow.CurrentProvince = null;
|
||||
movingArmiesTableControllerWide.CurrentProvince = null;
|
||||
factionsTableControllerNarrow.CurrentProvince = null;
|
||||
factionsTableControllerWide.CurrentProvince = null;
|
||||
heroesAndBattalionsPanelController.CurrentProvince = null;
|
||||
}
|
||||
|
||||
UpdateButtons();
|
||||
SetNextActiveProvinceButton();
|
||||
_dominionPanelController.ForceUpdate();
|
||||
dominionPanelControllerNarrow.ForceUpdate();
|
||||
dominionPanelControllerWide.ForceUpdate();
|
||||
SetMusic();
|
||||
|
||||
// Notify tutorial system of province selection
|
||||
@@ -494,14 +544,20 @@ namespace eagle {
|
||||
|
||||
provinceInfoPanelController.Model = _newModel;
|
||||
freeHeroesTableController.Model = _newModel;
|
||||
movingArmiesTableController.Model = _newModel;
|
||||
factionsTableController.Model = _newModel;
|
||||
movingArmiesTableControllerNarrow.Model = _newModel;
|
||||
movingArmiesTableControllerWide.Model = _newModel;
|
||||
factionsTableControllerNarrow.Model = _newModel;
|
||||
factionsTableControllerWide.Model = _newModel;
|
||||
heroesAndBattalionsPanelController.Model = _newModel;
|
||||
_commandPanelController.Model = _newModel;
|
||||
_dominionPanelController.Model = _newModel;
|
||||
dominionPanelControllerNarrow.Model = _newModel;
|
||||
dominionPanelControllerWide.Model = _newModel;
|
||||
mapController.Model = _newModel;
|
||||
|
||||
if (Model == null) { return; }
|
||||
if (Model == null) {
|
||||
goToBattleButton.gameObject.SetActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start onboarding after first model update (UI panels are now populated)
|
||||
if (oldModel == null && TutorialManager.Instance != null &&
|
||||
@@ -616,8 +672,10 @@ namespace eagle {
|
||||
}
|
||||
|
||||
freeHeroesTableController.UpdateTables();
|
||||
movingArmiesTableController.UpdateTables();
|
||||
factionsTableController.UpdateTables();
|
||||
movingArmiesTableControllerNarrow.UpdateTables();
|
||||
movingArmiesTableControllerWide.UpdateTables();
|
||||
factionsTableControllerNarrow.UpdateTables();
|
||||
factionsTableControllerWide.UpdateTables();
|
||||
heroesAndBattalionsPanelController.UpdateTables();
|
||||
|
||||
Canvas.ForceUpdateCanvases();
|
||||
@@ -664,8 +722,10 @@ namespace eagle {
|
||||
mapController.TargetedProvinces = new List<ProvinceId>();
|
||||
commandButtonPanelController.ClearButtonContents();
|
||||
freeHeroesTableController.UpdateTables();
|
||||
movingArmiesTableController.UpdateTables();
|
||||
factionsTableController.UpdateTables();
|
||||
movingArmiesTableControllerNarrow.UpdateTables();
|
||||
movingArmiesTableControllerWide.UpdateTables();
|
||||
factionsTableControllerNarrow.UpdateTables();
|
||||
factionsTableControllerWide.UpdateTables();
|
||||
heroesAndBattalionsPanelController.UpdateTables();
|
||||
}
|
||||
|
||||
@@ -682,6 +742,12 @@ namespace eagle {
|
||||
// Hide the button now, so if we get an exception below we can at least recover
|
||||
goToBattleButton.gameObject.SetActive(false);
|
||||
|
||||
if (Model == null) { throw new InvalidOperationException("GoToBattle: Model is null"); }
|
||||
|
||||
if (shardokContainer == null) {
|
||||
throw new InvalidOperationException("GoToBattle: shardokContainer is null");
|
||||
}
|
||||
|
||||
ShardokGameModel selectedModel;
|
||||
|
||||
var runningBattles = Model.RunningShardokGameModels.Values;
|
||||
@@ -691,14 +757,19 @@ namespace eagle {
|
||||
} else if (runningBattles.Any()) {
|
||||
selectedModel = runningBattles.First();
|
||||
} else {
|
||||
throw new ApplicationException(
|
||||
"Go to Battle button clicked, but there are no battles");
|
||||
throw new InvalidOperationException(
|
||||
"GoToBattle: No battles in RunningShardokGameModels");
|
||||
}
|
||||
|
||||
var shardokModel = Model.ShardokGameModels[selectedModel.ShardokGameId];
|
||||
|
||||
shardokCanvas.gameObject.SetActive(true);
|
||||
var shardokController = shardokCanvas.GetComponent<ShardokGameController>();
|
||||
shardokContainer.SetActive(true);
|
||||
var shardokController = shardokContainer.GetComponent<ShardokGameController>();
|
||||
if (shardokController == null) {
|
||||
throw new InvalidOperationException(
|
||||
"GoToBattle: shardokContainer missing ShardokGameController component");
|
||||
}
|
||||
|
||||
shardokController.SetUpGame(shardokModel);
|
||||
shardokController.SetConnection(errorHandler.PersistentClientConnection);
|
||||
gameObject.SetActive(false);
|
||||
|
||||
@@ -80,6 +80,14 @@ namespace eagle {
|
||||
public Action<IGameModel> UpdateAction { get; set; }
|
||||
public ReceiveNotes NoteRecipient { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Callback to check if there's a pending command with the specified token.
|
||||
/// Set by PersistentClientConnection. When this returns true, HandleAvailableCommands
|
||||
/// will set the token (for TryPendingCommands matching) but not load the commands,
|
||||
/// since the pending command will be posted and we'll get fresh commands after.
|
||||
/// </summary>
|
||||
public Func<long, bool> HasPendingCommandWithToken { get; set; }
|
||||
|
||||
private void Notify(Notification notification) {
|
||||
NoteRecipient(
|
||||
notification.Title,
|
||||
@@ -138,6 +146,11 @@ namespace eagle {
|
||||
private DateTime? _commandSubmittedTime;
|
||||
private const double ProcessingDisplayDelayMs = 500.0;
|
||||
|
||||
// Turn mismatch detection: server says YourTurn but client has no commands
|
||||
// Track when mismatch was first detected to avoid false positives during normal updates
|
||||
private DateTime? _turnMismatchDetectedTime;
|
||||
private const double TurnMismatchGracePeriodSeconds = 3.0;
|
||||
|
||||
// Track which Shardok games need full state resync after connection drop
|
||||
// Thread-safe: accessed from both connection thread and Unity main thread
|
||||
private readonly ConcurrentDictionary<string, bool> _shardokNeedsResync =
|
||||
@@ -292,18 +305,18 @@ namespace eagle {
|
||||
public void ReceiveGameUpdate(GameUpdate updateItem) {
|
||||
if (updateItem.StartingState != null) { HandleStartingState(updateItem.StartingState); }
|
||||
|
||||
// Store server-reported game status for UI (available on any update type)
|
||||
if (updateItem.ServerGameStatus != null) {
|
||||
ServerStatus = updateItem.ServerGameStatus;
|
||||
_connectionLogger.LogLine(
|
||||
$"[UPDATE] ServerGameStatus updated: {ServerStatus.Status}");
|
||||
}
|
||||
|
||||
switch (updateItem.GameUpdateDetailsCase) {
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
|
||||
// Clear processing state - we received a response from the server
|
||||
_commandSubmittedTime = null;
|
||||
|
||||
// Store server-reported game status for UI
|
||||
if (updateItem.ActionResultResponse.ServerGameStatus != null) {
|
||||
ServerStatus = updateItem.ActionResultResponse.ServerGameStatus;
|
||||
_connectionLogger.LogLine(
|
||||
$"[UPDATE] ServerGameStatus updated: {ServerStatus.Status}");
|
||||
}
|
||||
|
||||
// Note: _lastUnfilteredResultCount is updated on the gRPC thread in
|
||||
// UpdateResultCounts() before enqueueing. We don't update it here to avoid
|
||||
// race conditions where a backlogged MainQueue update overwrites a newer count.
|
||||
@@ -332,6 +345,9 @@ namespace eagle {
|
||||
"[UPDATE] SKIPPED - no results, has commands, token matches");
|
||||
}
|
||||
|
||||
// Check for turn state mismatch after processing update
|
||||
CheckForTurnMismatch();
|
||||
|
||||
break;
|
||||
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
|
||||
@@ -523,18 +539,27 @@ namespace eagle {
|
||||
|
||||
// Clear all stale state before applying new starting state.
|
||||
// This handles rewind/reset scenarios where the server sends an earlier state.
|
||||
// NOTE: We intentionally preserve LastPostedToken - if we have a pending command
|
||||
// from before the reconnect, we need to detect when the server sends us the same
|
||||
// token so we can skip loading those stale commands.
|
||||
_currentModel.ShardokGameModels.Clear();
|
||||
_shardokResultCounts.Clear();
|
||||
_shardokNeedsResync.Clear();
|
||||
_currentModel.CommandToken = null;
|
||||
_currentModel.LastPostedToken = -1;
|
||||
_currentModel.AvailableCommandsByProvince.Clear();
|
||||
_commandSubmittedTime = null;
|
||||
_turnMismatchDetectedTime = null;
|
||||
|
||||
_currentModel.GsView = startingState;
|
||||
_currentModel.BattalionTypes =
|
||||
startingState.BattalionTypes.ToDictionary(bt => bt.TypeId, bt => bt);
|
||||
_currentModel.ChronicleEntries = startingState.ChronicleEntries.ToList();
|
||||
// Don't overwrite existing chronicle entries with an empty list.
|
||||
// Chronicle entries are historical records that accumulate over time.
|
||||
// A stale StartingState (e.g., from early in the game before chronicles existed)
|
||||
// arriving after a current state could otherwise wipe out existing entries.
|
||||
if (startingState.ChronicleEntries.Any() || _currentModel.ChronicleEntries == null) {
|
||||
_currentModel.ChronicleEntries = startingState.ChronicleEntries.ToList();
|
||||
}
|
||||
|
||||
// For any outstanding battles in the new state, create models and mark for resync.
|
||||
bool needsResubscribe = false;
|
||||
@@ -582,9 +607,17 @@ namespace eagle {
|
||||
}
|
||||
|
||||
if (availableCommands.Token == _currentModel.LastPostedToken) {
|
||||
// We're receiving a token for a command we already posted. That probably indicates
|
||||
// a race, where we're receiving a game update from some other source before our
|
||||
// last command was processed.
|
||||
// We're receiving a token for a command we already posted. This can happen in two
|
||||
// scenarios:
|
||||
// 1. Race condition: receiving a game update from another source before our command
|
||||
// was processed.
|
||||
// 2. Our POST failed (e.g., due to disconnect) and the server is re-sending the
|
||||
// same
|
||||
// token after reconnection.
|
||||
//
|
||||
// In case 1, if we already have commands loaded, that's unexpected - throw.
|
||||
// In case 2, we have no commands and should accept the server's commands rather
|
||||
// than skipping, otherwise we'll be stuck with no commands.
|
||||
|
||||
if (_currentModel.CommandToken != null ||
|
||||
_currentModel.AvailableCommandsByProvince.Count > 0) {
|
||||
@@ -592,9 +625,12 @@ namespace eagle {
|
||||
$"Received token {availableCommands.Token} that matches last posted token, but we already have commands: {_currentModel.AvailableCommandsByProvince}");
|
||||
}
|
||||
|
||||
// Don't skip - accept the commands. If we successfully posted before, the server
|
||||
// will return BAD_TOKEN when we try to post again. But if our POST failed, we need
|
||||
// these commands to proceed.
|
||||
_connectionLogger.LogLine(
|
||||
$"Received token {availableCommands.Token} that matches last posted token; skipping");
|
||||
return;
|
||||
$"Received token {availableCommands.Token} that matches last posted token; accepting commands (POST may have failed)");
|
||||
_currentModel.LastPostedToken = -1; // Clear to avoid future skips
|
||||
}
|
||||
|
||||
_connectionLogger.LogLine(
|
||||
@@ -615,6 +651,19 @@ namespace eagle {
|
||||
|
||||
_currentModel.CommandToken = availableCommands.Token;
|
||||
|
||||
// Check if there's a pending command with this token waiting to be posted.
|
||||
// If so, don't load the commands - they're stale (we already acted on them).
|
||||
// The pending command will be posted by TryPendingCommands, and we'll get
|
||||
// fresh commands after it's processed.
|
||||
if (HasPendingCommandWithToken != null &&
|
||||
HasPendingCommandWithToken(availableCommands.Token)) {
|
||||
_connectionLogger.LogLine(
|
||||
$"Token {availableCommands.Token} matches pending command; " +
|
||||
"skipping command load, pending command will be posted");
|
||||
_currentModel.AvailableCommandsByProvince.Clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// does this not clear existing UX if there's a token replacement?
|
||||
_currentModel.AvailableCommandsByProvince =
|
||||
new Dictionary<UnitId, OneProvinceAvailableCommands>(
|
||||
@@ -925,6 +974,69 @@ namespace eagle {
|
||||
(DateTime.UtcNow - _commandSubmittedTime.Value).TotalMilliseconds >
|
||||
ProcessingDisplayDelayMs;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Turn Mismatch Detection
|
||||
|
||||
/// <summary>
|
||||
/// Checks for turn state mismatch: server reports YourTurn but client has no commands.
|
||||
/// If mismatch persists beyond the grace period, logs an error and triggers reconnection.
|
||||
/// </summary>
|
||||
private void CheckForTurnMismatch() {
|
||||
// Only check if we have server status
|
||||
if (ServerStatus == null) {
|
||||
_turnMismatchDetectedTime = null;
|
||||
return;
|
||||
}
|
||||
|
||||
bool serverSaysYourTurn = ServerStatus.Status == ServerGameStatus.Types.Status.YourTurn;
|
||||
bool clientHasNoCommands = _currentModel.AvailableCommandsByProvince.Count == 0 &&
|
||||
!_currentModel.CommandToken.HasValue;
|
||||
bool isProcessingCommand = _commandSubmittedTime.HasValue;
|
||||
|
||||
// Mismatch: server says it's our turn but we have no commands to act on
|
||||
// Exclude cases where we just submitted a command (waiting for response)
|
||||
bool hasMismatch = serverSaysYourTurn && clientHasNoCommands && !isProcessingCommand;
|
||||
|
||||
if (!hasMismatch) {
|
||||
// No mismatch - reset detection state
|
||||
_turnMismatchDetectedTime = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Mismatch detected - track when it started
|
||||
if (!_turnMismatchDetectedTime.HasValue) {
|
||||
_turnMismatchDetectedTime = DateTime.UtcNow;
|
||||
_connectionLogger.LogLine(
|
||||
"[TURN_MISMATCH] Detected: server says YourTurn but client has no commands. " +
|
||||
"Waiting for grace period...");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if grace period has elapsed
|
||||
var elapsedSeconds = (DateTime.UtcNow - _turnMismatchDetectedTime.Value).TotalSeconds;
|
||||
if (elapsedSeconds < TurnMismatchGracePeriodSeconds) { return; }
|
||||
|
||||
// Grace period exceeded - this is a real mismatch, trigger recovery
|
||||
_connectionLogger.LogLine(
|
||||
$"[TURN_MISMATCH] ERROR: Turn mismatch persisted for {elapsedSeconds:F1}s. " +
|
||||
$"ServerStatus={ServerStatus.Status}, " +
|
||||
$"AvailableCommands={_currentModel.AvailableCommandsByProvince.Count}, " +
|
||||
$"CommandToken={_currentModel.CommandTokenString}, " +
|
||||
$"LastPostedToken={_currentModel.LastPostedToken}. " +
|
||||
"Triggering reconnection to recover state.");
|
||||
|
||||
// Throw exception for ErrorHandler to capture (provides stack trace for debugging)
|
||||
var errorMessage =
|
||||
$"Turn mismatch detected: server reports YourTurn but client has no commands " +
|
||||
$"after {elapsedSeconds:F1}s. Reconnecting to recover state.";
|
||||
ErrorHandler?.Add(new InvalidOperationException(errorMessage));
|
||||
|
||||
// Reset detection state and trigger reconnection
|
||||
_turnMismatchDetectedTime = null;
|
||||
PersistentConnection.ForceReconnect();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
+7
@@ -9,6 +9,13 @@ namespace eagle {
|
||||
public Int64? CurrentEagleToken { get; }
|
||||
public Int64? CurrentShardokToken(string shardokGameId);
|
||||
|
||||
/// <summary>
|
||||
/// Callback to check if there's a pending command with the specified token.
|
||||
/// Set by PersistentClientConnection. Used to skip loading stale commands
|
||||
/// when a pending command is about to be posted.
|
||||
/// </summary>
|
||||
public Func<long, bool> HasPendingCommandWithToken { get; set; }
|
||||
|
||||
public void ReceiveGameUpdate(GameUpdate update);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -14,6 +14,7 @@ namespace eagle {
|
||||
|
||||
public RawImage mapBWImage;
|
||||
public List<RawImage> provinceImagesList;
|
||||
public ProvinceWeatherController weatherController;
|
||||
|
||||
public TMP_Text provinceNameText;
|
||||
public TMP_Text provinceOwnerText;
|
||||
@@ -111,6 +112,8 @@ namespace eagle {
|
||||
SetDefaultProvinceColor(province.Id);
|
||||
}
|
||||
|
||||
if (weatherController != null) { weatherController.UpdateWeatherEffects(value); }
|
||||
|
||||
eagleGameController.ProvinceWasSelected(_selectedProvinceId);
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user