mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 01:35:42 +00:00
Compare commits
137
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 |
@@ -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 .
|
||||
@@ -23,6 +23,10 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# Runner-specific build directory to allow parallel builds on multiple runners
|
||||
EAGLE0_BUILD_DIR: /tmp/eagle0-${{ github.run_id }}
|
||||
|
||||
jobs:
|
||||
build-ios-addressables:
|
||||
runs-on: [self-hosted, macOS, unity-mac]
|
||||
@@ -36,13 +40,16 @@ jobs:
|
||||
- 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 "/tmp/eagle0/editor_ios_addressables.log"
|
||||
run: ./ci/github_actions/build_ios_addressables.sh "$EAGLE0_BUILD_DIR/editor_ios_addressables.log"
|
||||
|
||||
- name: Persist Library/
|
||||
if: success()
|
||||
@@ -62,5 +69,8 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: editor_ios_addressables.log
|
||||
path: /tmp/eagle0/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 }}"
|
||||
@@ -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"
|
||||
@@ -5,14 +5,18 @@
|
||||
|
||||
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: //')
|
||||
|
||||
# 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:-"/tmp/eagle0/editor_ios_addressables.log"}
|
||||
LOG_PATH=${1:-"${BUILD_BASE}/editor_ios_addressables.log"}
|
||||
|
||||
echo "Building iOS Addressables"
|
||||
|
||||
|
||||
@@ -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?
|
||||
+67
-25
@@ -126,6 +126,27 @@ All 26 tracks have CC licenses with proper attribution:
|
||||
|
||||
---
|
||||
|
||||
## 2b. Creative Commons Sound Effects
|
||||
|
||||
**Location:** `Assets/Shardok/Sounds/`
|
||||
|
||||
| File | Description | Artist | License | Source |
|
||||
|------|-------------|--------|---------|--------|
|
||||
| `rain_loop.ogg` | Rain falling on clay roof tiles (loopable) | aesqe | CC BY 4.0 | [Freesound #37618](https://freesound.org/people/aesqe/sounds/37618/) |
|
||||
| `blizzard_wind_loop.wav` | Wind draft loop (indoor recording, loops seamlessly) | nsstudios | CC BY 4.0 | [Freesound #651540](https://freesound.org/people/nsstudios/sounds/651540/) |
|
||||
| `thunder_distant.mp3` | Distant thunder rumble | LittleRainySeasons | CC BY 4.0 | [Freesound #351526](https://freesound.org/people/LittleRainySeasons/sounds/351526/) |
|
||||
| `thunder_loud.mp3` | Loud thunder clap | mokasza | CC BY 4.0 | [Freesound #810746](https://freesound.org/people/mokasza/sounds/810746/) |
|
||||
| `thunder_crack.wav` | Thunder crack | OneSoundToRuleThemAll | CC BY 4.0 | [Freesound #238796](https://freesound.org/people/OneSoundToRuleThemAll/sounds/238796/) |
|
||||
| `thunder_clap.wav` | Thunder clap | FreqMan | CC BY 4.0 | [Freesound #32544](https://freesound.org/people/FreqMan/sounds/32544/) |
|
||||
|
||||
**Location:** `Assets/Shardok/soundEffects/`
|
||||
|
||||
| File | Description | Artist | License | Source |
|
||||
|------|-------------|--------|---------|--------|
|
||||
| `runaway.mp3` | Medieval army running loop (gravel + metal/chain) | Yap_Audio_Production | CC BY 4.0 | [Freesound #218997](https://freesound.org/people/Yap_Audio_Production/sounds/218997/) |
|
||||
|
||||
---
|
||||
|
||||
## 3. CC0 / Public Domain Assets
|
||||
|
||||
### SimpleFileBrowser Icons
|
||||
@@ -155,11 +176,11 @@ All 26 tracks have CC licenses with proper attribution:
|
||||
- `undead_break_control.mp3`
|
||||
- `undead_grew.wav`
|
||||
|
||||
**⚠️ MUST REPLACE:**
|
||||
**⚠️ 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` - 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.
|
||||
@@ -171,10 +192,16 @@ Owner believes these are from: Fantasy Interface Sounds, Medieval Combat Sounds,
|
||||
- `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
|
||||
- **Location:** `Assets/free_icons/`
|
||||
- **Count:** 8 PNG weather icons
|
||||
- **Status:** Verify "free" means commercially usable
|
||||
### ~~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/`
|
||||
@@ -216,26 +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**~~ - **RESOLVED** (2025-01-23): Replaced with game-icons.net CC BY 3.0 icons
|
||||
1. ~~**Clip art images**~~ - **RESOLVED** (2026-01-23): Replaced with properly licensed alternatives
|
||||
|
||||
2. **Shardok sound effects** - 3 files must be replaced:
|
||||
- `anybody.mp3` - licensing issue
|
||||
- `burnination.mp3` - licensing issue
|
||||
- `runaway.mp3` - licensing issue
|
||||
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
|
||||
|
||||
Remaining 31 files presumed from Asset Store purchases; 3 verified from Zombie Monster Undead Collection.
|
||||
### Low Priority (Verify):
|
||||
|
||||
3. ~~**Terrain Hexes**~~ - **VERIFIED** (2026-01-23): Confirmed Unity Asset Store purchase
|
||||
3. **Dima Koltsov tracks** - 2 of 5 not verified: `Forest Queen Tale`, `Clouds` (presumed CC BY 4.0 like his other tracks)
|
||||
|
||||
4. ~~**StrategyGameIcons**~~ - **VERIFIED** (2026-01-23): Unity Asset Store purchase (REXARD)
|
||||
### Already Resolved:
|
||||
|
||||
5. ~~**Medieval: Victory Theme**~~ - **VERIFIED** (2026-01-23): CC0 Public Domain by RandomMind ([Chosic](https://www.chosic.com/download-audio/28492/))
|
||||
4. ~~**Free Icons**~~ - **RESOLVED** (2026-01-27): All replaced with Asset Store equivalents, folder deleted
|
||||
|
||||
6. ~~**Dima Koltsov tracks**~~ - **MOSTLY VERIFIED** (2026-01-23): 3 of 5 confirmed CC BY 4.0 via YouTube. 2 remaining (Forest Queen Tale, Clouds) presumed same license.
|
||||
5. ~~**Terrain Hexes**~~ - **VERIFIED** (2026-01-23): Confirmed Unity Asset Store purchase
|
||||
|
||||
7. ~~**Discord logo**~~ - **OK** (2026-01-23): Usage complies with Discord brand guidelines for "Login with Discord" button
|
||||
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:
|
||||
|
||||
@@ -249,13 +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~~ **DONE** - see Section 4
|
||||
2. ~~Verify source of `Assets/Shardok/soundEffects/` MP3s~~ **MOSTLY DONE** - 3 files flagged for replacement, rest presumed Asset Store
|
||||
3. ~~Verify source of `Assets/Terrain Hexes/`~~ **DONE** - confirmed Asset Store purchase
|
||||
4. ~~Verify source of `Assets/StrategyGameIcons/`~~ **DONE** - Unity Asset Store (REXARD)
|
||||
5. ~~Replace Dima Koltsov Audius tracks~~ **MOSTLY DONE** - 3/5 confirmed CC BY 4.0, 2 presumed same
|
||||
6. ~~Find source of Medieval: Victory Theme or replace~~ **DONE** - CC0 Public Domain by RandomMind
|
||||
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
|
||||
@@ -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
|
||||
|
||||
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;
|
||||
@@ -49,6 +54,9 @@ namespace Auth {
|
||||
// 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);
|
||||
@@ -73,6 +81,29 @@ namespace Auth {
|
||||
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");
|
||||
@@ -80,6 +111,116 @@ namespace Auth {
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Set the server URLs for OAuth and game requests. Call this before any OAuth operations.
|
||||
/// </summary>
|
||||
@@ -97,10 +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; }
|
||||
}
|
||||
@@ -114,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();
|
||||
@@ -124,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;
|
||||
}
|
||||
@@ -218,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();
|
||||
@@ -308,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.");
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
+46
-19
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -827,6 +848,12 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
_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;
|
||||
@@ -867,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);
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
+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) {
|
||||
|
||||
+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) {
|
||||
|
||||
+4
-1
@@ -126,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 ||
|
||||
|
||||
+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();
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -61,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;
|
||||
@@ -157,6 +163,15 @@ namespace eagle {
|
||||
factionsAndMovingArmiesNarrow.SetActive(false);
|
||||
dominionPanelControllerWide.gameObject.SetActive(true);
|
||||
dominionPanelControllerNarrow.gameObject.SetActive(false);
|
||||
if (wideSpacer != null) wideSpacer.SetActive(true);
|
||||
|
||||
// Match HeroesAndBattalions width to Popup Canvas spacers
|
||||
if (heroesAndBattalionsLayoutElement != null && popupCanvasLeftSpacer != null &&
|
||||
popupCanvasRightSpacer != null) {
|
||||
heroesAndBattalionsLayoutElement.preferredWidth =
|
||||
popupCanvasLeftSpacer.preferredWidth +
|
||||
popupCanvasRightSpacer.preferredWidth;
|
||||
}
|
||||
|
||||
provinceInfoPanelController.WideScreen = true;
|
||||
} else {
|
||||
@@ -165,6 +180,13 @@ namespace eagle {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -531,11 +539,13 @@ 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;
|
||||
@@ -543,7 +553,13 @@ namespace eagle {
|
||||
_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;
|
||||
@@ -591,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) {
|
||||
@@ -601,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(
|
||||
@@ -624,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>(
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -56,6 +56,9 @@ namespace eagle.Notifications {
|
||||
{ AllianceRejectedDetails, AllianceRejectedDetailsNotificationGenerator.Generator },
|
||||
{ BreakAllianceAcceptedDetails, BreakAllianceAcceptedNotificationGenerator.Generator },
|
||||
{ VassalExiledDetails, VassalExiledDetailsNotificationGenerator.Generator },
|
||||
{ VassalRisesDetails, VassalRisesDetailsNotificationGenerator.Generator },
|
||||
{ LowFoodWarningDetails, LowFoodWarningDetailsNotificationGenerator.Generator },
|
||||
{ StarvationDetails, StarvationDetailsNotificationGenerator.Generator },
|
||||
{ WithdrewForTruce, WithdrewForTruceDetailsNotificationGenerator.Generator },
|
||||
{ ProfessionGainedDetails, ProfessionGainedDetailsNotificationGenerator.Generator },
|
||||
{ NewFactionHeadDetails, NewFactionHeadDetailsNotificationGenerator.Generator }
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Net.Eagle0.Eagle.Common;
|
||||
using Net.Eagle0.Eagle.Views;
|
||||
|
||||
namespace eagle.Notifications.ARNNotifications {
|
||||
using ProvinceId = Int32;
|
||||
using HeroId = Int32;
|
||||
|
||||
public static class LowFoodWarningDetailsNotificationGenerator {
|
||||
public static readonly ARNNotificationGenerator Generator = GenerateNotifications;
|
||||
|
||||
private static readonly Random Random = new();
|
||||
|
||||
private static readonly List<string> NotificationTitles = new() {
|
||||
"Granaries Running Low",
|
||||
"Food Shortage Warning",
|
||||
"Dwindling Supplies",
|
||||
"Hungry Times Ahead",
|
||||
"Provisions Depleting",
|
||||
"Crisis Looming",
|
||||
"Empty Granaries"
|
||||
};
|
||||
|
||||
private static string GetNotificationTitle() {
|
||||
return NotificationTitles[Random.Next(NotificationTitles.Count)];
|
||||
}
|
||||
|
||||
private static IEnumerable<Notification> GenerateNotifications(
|
||||
Net.Eagle0.Eagle.Common.Notification notification,
|
||||
IGameModel currentModel) {
|
||||
var details = notification.Details.LowFoodWarningDetails;
|
||||
var province = currentModel.Provinces[details.ProvinceId];
|
||||
var rulingHeroId = province.FullInfo?.RulingHeroId;
|
||||
|
||||
string textTemplate;
|
||||
|
||||
if (details.MonthsOfFoodRemaining <= 1) {
|
||||
textTemplate =
|
||||
$"Dire news from {{RulingHero}} in {province.Name}. With only {details.CurrentFood} food remaining and monthly consumption of {details.MonthlyConsumption}, starvation looms within the month.\n\n";
|
||||
} else {
|
||||
textTemplate =
|
||||
$"{{RulingHero}} reports that {province.Name} has only {details.MonthsOfFoodRemaining} months of food remaining. Current stores: {details.CurrentFood}. Monthly consumption: {details.MonthlyConsumption}.\n\n";
|
||||
}
|
||||
|
||||
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)>();
|
||||
var displayedHeroes = new List<HeroView>();
|
||||
|
||||
if (rulingHeroId.HasValue &&
|
||||
currentModel.Heroes.TryGetValue(rulingHeroId.Value, out var rulingHero)) {
|
||||
heroPlaceholders["RulingHero"] = (rulingHero.NameTextId, "The governor");
|
||||
displayedHeroes.Add(rulingHero);
|
||||
} else {
|
||||
heroPlaceholders["RulingHero"] = ("", "The governor");
|
||||
}
|
||||
|
||||
yield return DynamicTextNotification.StreamingDynamicNotification(
|
||||
title: GetNotificationTitle(),
|
||||
textTemplate: textTemplate,
|
||||
heroPlaceholders: heroPlaceholders,
|
||||
llmId: notification.LlmId,
|
||||
provinceIds: new List<ProvinceId> { details.ProvinceId },
|
||||
displayedHeroes: displayedHeroes);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4b9c501e6360f4bd5949db2f2feb7dd9
|
||||
+9
-9
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using Net.Eagle0.Eagle.Common;
|
||||
using Net.Eagle0.Eagle.Views;
|
||||
|
||||
namespace eagle.Notifications.ARNNotifications {
|
||||
@@ -10,26 +11,25 @@ namespace eagle.Notifications.ARNNotifications {
|
||||
IGameModel currentModel) {
|
||||
var details = notification.Details.OutlawApprehendedDetails;
|
||||
var province = currentModel.Provinces[details.ProvinceId];
|
||||
var factionName = currentModel.FactionName(details.FactionId);
|
||||
var factionHead =
|
||||
currentModel.Heroes[currentModel.MaybeDestroyedFaction(details.FactionId)
|
||||
.FactionHeadId];
|
||||
var apprehended = currentModel.Heroes[details.ApprehendedHeroId];
|
||||
var apprehending = currentModel.Heroes[details.ApprehendingHeroId];
|
||||
|
||||
string noteTitle = "Outlaw Apprehended";
|
||||
string textTemplate =
|
||||
$"{factionName} apprehended the outlaw {{ApprehendedHero}} in {province.Name}.";
|
||||
$"{{ApprehendingHero}} apprehended the outlaw {{ApprehendedHero}} in {province.Name}. The captured outlaw reflects:\n\n";
|
||||
|
||||
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
|
||||
{ "ApprehendedHero", (apprehended.NameTextId, "an outlaw") }
|
||||
{ "ApprehendedHero", (apprehended.NameTextId, "an outlaw") },
|
||||
{ "ApprehendingHero", (apprehending.NameTextId, "a hero") }
|
||||
};
|
||||
|
||||
yield return new DynamicTextNotification(
|
||||
yield return DynamicTextNotification.StreamingDynamicNotification(
|
||||
title: noteTitle,
|
||||
textTemplate: textTemplate,
|
||||
heroPlaceholders: heroPlaceholders,
|
||||
singleProvinceId: details.ProvinceId,
|
||||
displayedHeroes: new List<HeroView> { factionHead, apprehended });
|
||||
llmId: notification.LlmId,
|
||||
provinceIds: new List<int> { details.ProvinceId },
|
||||
displayedHeroes: new List<HeroView> { apprehending, apprehended });
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Net.Eagle0.Eagle.Common;
|
||||
using Net.Eagle0.Eagle.Views;
|
||||
|
||||
namespace eagle.Notifications.ARNNotifications {
|
||||
using ProvinceId = Int32;
|
||||
using HeroId = Int32;
|
||||
|
||||
public static class StarvationDetailsNotificationGenerator {
|
||||
public static readonly ARNNotificationGenerator Generator = GenerateNotifications;
|
||||
|
||||
private static readonly Random Random = new();
|
||||
|
||||
private static readonly List<string> NotificationTitles = new() {
|
||||
"Famine Strikes",
|
||||
"Troops Starving",
|
||||
"Mass Starvation",
|
||||
"Hunger Claims Lives",
|
||||
"Empty Bellies, Empty Ranks",
|
||||
"Famine's Toll",
|
||||
"Starvation Crisis"
|
||||
};
|
||||
|
||||
private static string GetNotificationTitle() {
|
||||
return NotificationTitles[Random.Next(NotificationTitles.Count)];
|
||||
}
|
||||
|
||||
private static IEnumerable<Notification> GenerateNotifications(
|
||||
Net.Eagle0.Eagle.Common.Notification notification,
|
||||
IGameModel currentModel) {
|
||||
var details = notification.Details.StarvationDetails;
|
||||
var province = currentModel.Provinces[details.ProvinceId];
|
||||
|
||||
string textTemplate =
|
||||
$"Catastrophe in {province.Name}! The province has run out of food and troops are starving. {{RulingHero}} addresses the realm:\n\n";
|
||||
|
||||
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)>();
|
||||
var displayedHeroes = new List<HeroView>();
|
||||
|
||||
if (currentModel.Heroes.TryGetValue(details.RulingHeroId, out var rulingHero)) {
|
||||
heroPlaceholders["RulingHero"] = (rulingHero.NameTextId, "The governor");
|
||||
displayedHeroes.Add(rulingHero);
|
||||
} else {
|
||||
heroPlaceholders["RulingHero"] = ("", "The governor");
|
||||
}
|
||||
|
||||
yield return DynamicTextNotification.StreamingDynamicNotification(
|
||||
title: GetNotificationTitle(),
|
||||
textTemplate: textTemplate,
|
||||
heroPlaceholders: heroPlaceholders,
|
||||
llmId: notification.LlmId,
|
||||
provinceIds: new List<ProvinceId> { details.ProvinceId },
|
||||
displayedHeroes: displayedHeroes);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d60107aa67f964ff995bf9e255bfabec
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Net.Eagle0.Eagle.Common;
|
||||
using Net.Eagle0.Eagle.Views;
|
||||
|
||||
namespace eagle.Notifications.ARNNotifications {
|
||||
using ProvinceId = Int32;
|
||||
using HeroId = Int32;
|
||||
|
||||
public static class VassalRisesDetailsNotificationGenerator {
|
||||
public static readonly ARNNotificationGenerator Generator = GenerateNotifications;
|
||||
|
||||
private static readonly Random Random = new();
|
||||
|
||||
private static readonly List<string> NotificationTitles = new() {
|
||||
"A Vassal Rises",
|
||||
"From Servant to Leader",
|
||||
"Unexpected Succession",
|
||||
"Rise of the Unsung",
|
||||
"Fate's Choice",
|
||||
"The Loyal Promoted",
|
||||
"New Dawn"
|
||||
};
|
||||
|
||||
private static string GetNotificationTitle() {
|
||||
return NotificationTitles[Random.Next(NotificationTitles.Count)];
|
||||
}
|
||||
|
||||
private static ProvinceId? FindProvinceForHero(HeroId heroId, IGameModel model) {
|
||||
foreach (var province in model.Provinces.Values) {
|
||||
if (province.FullInfo?.RulingFactionHeroIds.Contains(heroId) == true) {
|
||||
return province.Id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<Notification> GenerateNotifications(
|
||||
Net.Eagle0.Eagle.Common.Notification notification,
|
||||
IGameModel currentModel) {
|
||||
var details = notification.Details.VassalRisesDetails;
|
||||
var risingHero = currentModel.Heroes[details.RisingHeroId];
|
||||
|
||||
string textTemplate;
|
||||
List<ProvinceId> affectedProvinces;
|
||||
|
||||
if (details.FactionId == currentModel.PlayerId) {
|
||||
// Player's own faction - a vassal has risen to lead
|
||||
if (details.IsOnlyLeader) {
|
||||
textTemplate =
|
||||
"With all your faction's leaders fallen or imprisoned, your loyal vassal {RisingHero} has risen to lead your forces.\n\n";
|
||||
} else {
|
||||
textTemplate =
|
||||
"Your vassal {RisingHero} has risen to join the leadership of your faction.\n\n";
|
||||
}
|
||||
|
||||
var heroProvinceId = FindProvinceForHero(details.RisingHeroId, currentModel);
|
||||
affectedProvinces = heroProvinceId.HasValue
|
||||
? new List<ProvinceId> { heroProvinceId.Value }
|
||||
: currentModel.ProvincesForFaction(details.FactionId);
|
||||
} else {
|
||||
// Another faction
|
||||
var factionName = currentModel.FactionName(details.FactionId);
|
||||
if (details.IsOnlyLeader) {
|
||||
textTemplate =
|
||||
$"With all of {factionName}'s leaders fallen or imprisoned, {{RisingHero}} has risen from obscurity to lead their forces.\n\n";
|
||||
} else {
|
||||
textTemplate =
|
||||
$"{{RisingHero}} has risen to join the leadership of {factionName}.\n\n";
|
||||
}
|
||||
affectedProvinces = currentModel.ProvincesForFaction(details.FactionId);
|
||||
}
|
||||
|
||||
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
|
||||
{ "RisingHero", (risingHero.NameTextId, "A vassal") }
|
||||
};
|
||||
|
||||
var displayedHeroes = new List<HeroView> { risingHero };
|
||||
|
||||
yield return DynamicTextNotification.StreamingDynamicNotification(
|
||||
title: GetNotificationTitle(),
|
||||
textTemplate: textTemplate,
|
||||
heroPlaceholders: heroPlaceholders,
|
||||
llmId: notification.LlmId,
|
||||
provinceIds: affectedProvinces,
|
||||
displayedHeroes: displayedHeroes);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c8e8d89a0640d435caed284731cef057
|
||||
+11
-3
@@ -10,9 +10,17 @@ namespace eagle.Notifications {
|
||||
IGameModel currentModel) {
|
||||
var victor = actionResultView.GameStateDiff.NewVictor;
|
||||
var victorString = victor.HasValue ? currentModel.FactionName(victor.Value) : "Nobody";
|
||||
var victorHead =
|
||||
currentModel
|
||||
.Heroes[currentModel.MaybeDestroyedFaction(victor.Value).FactionHeadId];
|
||||
|
||||
// Only show victor's hero if there is a victor
|
||||
HeroView victorHead = null;
|
||||
if (victor.HasValue) {
|
||||
var victorFaction = currentModel.MaybeDestroyedFaction(victor.Value);
|
||||
if (victorFaction != null &&
|
||||
currentModel.Heroes.TryGetValue(victorFaction.FactionHeadId, out var hero)) {
|
||||
victorHead = hero;
|
||||
}
|
||||
}
|
||||
|
||||
yield return new Notification(
|
||||
title: "Game Over",
|
||||
text: $"The game is over! {victorString} has won!",
|
||||
|
||||
+48
-20
@@ -26,7 +26,6 @@ namespace eagle {
|
||||
|
||||
public interface ILobbySubscriber {
|
||||
void ReceiveLobbyUpdate(LobbyResponse lobbyResponse);
|
||||
void ReceivePregeneratedTextUpdate(PregeneratedTextResponse response);
|
||||
void HandleJoinGameResponse(JoinGameResponse response);
|
||||
void HandleCreateGameResponse(CreateGameResponse response);
|
||||
void HandleCustomBattleResponse(CustomBattleResponse response);
|
||||
@@ -174,12 +173,38 @@ namespace eagle {
|
||||
|
||||
private List<PostCommandRequest> _pendingCommands = new();
|
||||
|
||||
/// <summary>
|
||||
/// Checks if there's a pending Eagle command with the specified token for the given game.
|
||||
/// Used by GameModelUpdater to skip loading stale commands when a pending command
|
||||
/// is about to be posted.
|
||||
/// </summary>
|
||||
public bool HasPendingEagleCommandWithToken(long gameId, long token) {
|
||||
lock (this) {
|
||||
foreach (var cmd in _pendingCommands) {
|
||||
if (cmd.GameId == gameId &&
|
||||
cmd.Command.SealedValueCase ==
|
||||
UniversalCommand.SealedValueOneofCase.EagleCommand &&
|
||||
cmd.Command.EagleCommand.EagleToken == token) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Callback invoked when the underlying gRPC channel is disposed and cannot be recovered.
|
||||
/// The caller should create a new EagleConnection and PersistentClientConnection.
|
||||
/// </summary>
|
||||
public Action OnChannelDead { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Callback invoked when a command is rejected by the server with an error.
|
||||
/// The string parameter contains the error message from the server.
|
||||
/// </summary>
|
||||
public Action<string> OnCommandError { get; set; }
|
||||
|
||||
// Pending subscription acknowledgments - used to wait for server confirmation
|
||||
private readonly Dictionary<EagleGameId, TaskCompletionSource<SubscriptionAck>>
|
||||
_pendingSubscriptionAcks = new();
|
||||
@@ -682,6 +707,13 @@ namespace eagle {
|
||||
toStream = subscriber;
|
||||
}
|
||||
|
||||
// Set up callback so subscriber can check for pending commands.
|
||||
// This allows HandleAvailableCommands to skip loading stale commands
|
||||
// when a pending command with the same token is about to be posted.
|
||||
var gameId = subscriber.GameId;
|
||||
subscriber.HasPendingCommandWithToken = (token) =>
|
||||
HasPendingEagleCommandWithToken(gameId, token);
|
||||
|
||||
// Stream OUTSIDE lock (can await)
|
||||
return await StreamOneGameAsync(toStream);
|
||||
}
|
||||
@@ -1075,19 +1107,6 @@ namespace eagle {
|
||||
break;
|
||||
}
|
||||
|
||||
case UpdateStreamResponse.ResponseDetailsOneofCase
|
||||
.PregeneratedTextResponse: {
|
||||
var lobbySubscriber = _lobbySubscriber;
|
||||
if (lobbySubscriber != null) {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
lobbySubscriber.ReceivePregeneratedTextUpdate(
|
||||
current.PregeneratedTextResponse);
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case UpdateStreamResponse.ResponseDetailsOneofCase.JoinGameResponse: {
|
||||
var lobbySubscriber = _lobbySubscriber;
|
||||
if (lobbySubscriber != null) {
|
||||
@@ -1159,14 +1178,19 @@ namespace eagle {
|
||||
var postResponse = current.PostCommandResponse;
|
||||
if (postResponse.Status == PostCommandResponse.Types.Status.Error) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[POST] Server returned ERROR: {postResponse.ErrorMessage}");
|
||||
$"[POST] Server returned ERROR for game {postResponse.GameId}: " +
|
||||
$"{postResponse.ErrorMessage}");
|
||||
LogConnectionEvent(
|
||||
"post_command_error",
|
||||
postResponse.ErrorMessage);
|
||||
|
||||
// Disconnect and let normal reconnect flow handle recovery
|
||||
_streamingCall?.Dispose();
|
||||
_streamingCall = null;
|
||||
// Command was rejected by server. Remove from pending to avoid
|
||||
// infinite retry loop, and refresh state to get valid commands.
|
||||
RemovePendingCommandsForGame(postResponse.GameId);
|
||||
RefreshGameSubscription(postResponse.GameId);
|
||||
|
||||
// Notify UI to display the error to the user
|
||||
OnCommandError?.Invoke(postResponse.ErrorMessage);
|
||||
} else if (
|
||||
postResponse.Status ==
|
||||
PostCommandResponse.Types.Status.BadToken) {
|
||||
@@ -1215,7 +1239,9 @@ namespace eagle {
|
||||
CancelAllPendingSubscriptionAcks();
|
||||
}
|
||||
LogConnectionEvent("disconnect", "StreamEndedNormally");
|
||||
ScheduleReconnect("StreamEndedNormally");
|
||||
// Stream ended normally - reconnect immediately without backoff
|
||||
LogFlow("RECONNECT_IMMEDIATE reason=StreamEndedNormally");
|
||||
RunConnectAsync();
|
||||
}
|
||||
} catch (RpcException e) {
|
||||
lock (this) {
|
||||
@@ -1251,7 +1277,9 @@ namespace eagle {
|
||||
"Deadline exceeded",
|
||||
StatusCode.DeadlineExceeded);
|
||||
_remoteEagleClientLogger.LogLine("Got DeadlineExceeded");
|
||||
ScheduleReconnect("DeadlineExceeded");
|
||||
// Deadline exceeded is expected (5 min timeout) - reconnect immediately
|
||||
LogFlow("RECONNECT_IMMEDIATE reason=DeadlineExceeded");
|
||||
RunConnectAsync();
|
||||
break;
|
||||
|
||||
case StatusCode.Unavailable:
|
||||
|
||||
+1
-1
@@ -382,7 +382,7 @@ namespace eagle {
|
||||
|
||||
public void DominionViewToggleClicked(bool value) {
|
||||
dominionPanelController.gameObject.SetActive(value);
|
||||
this.gameObject.SetActive(!value);
|
||||
panel.SetActive(!value); // Hide panel content, not the entire controller
|
||||
}
|
||||
|
||||
void Start() { panel.SetActive(false); }
|
||||
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
using System.Collections.Generic;
|
||||
using Net.Eagle0.Eagle.Common;
|
||||
using Net.Eagle0.Eagle.Views;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using ProvinceId = System.Int32;
|
||||
|
||||
namespace eagle {
|
||||
public class ProvinceWeatherController : MonoBehaviour {
|
||||
[Header("References")]
|
||||
public MapController mapController;
|
||||
|
||||
[Header("Effect Materials")]
|
||||
public Material blizzardMaterial;
|
||||
public Material floodMaterial;
|
||||
public Material droughtMaterial;
|
||||
|
||||
private Dictionary<ProvinceId, ProvinceEvent.SealedValueOneofCase> _activeEffects = new();
|
||||
private Dictionary<ProvinceId, GameObject> _overlayObjects = new();
|
||||
private Dictionary<ProvinceId, Material> _instancedMaterials = new();
|
||||
|
||||
public void UpdateWeatherEffects(IGameModel model) {
|
||||
if (model?.Provinces == null) { return; }
|
||||
|
||||
var toRemove = new List<ProvinceId>();
|
||||
foreach (var kvp in _activeEffects) {
|
||||
if (!model.Provinces.ContainsKey(kvp.Key) ||
|
||||
!HasWeatherEvent(model.Provinces[kvp.Key], kvp.Value)) {
|
||||
DisableEffect(kvp.Key);
|
||||
toRemove.Add(kvp.Key);
|
||||
}
|
||||
}
|
||||
foreach (var id in toRemove) { _activeEffects.Remove(id); }
|
||||
|
||||
foreach (var province in model.Provinces.Values) {
|
||||
var weatherEvent = GetPriorityWeatherEvent(province);
|
||||
if (weatherEvent != ProvinceEvent.SealedValueOneofCase.None) {
|
||||
if (!_activeEffects.ContainsKey(province.Id) ||
|
||||
_activeEffects[province.Id] != weatherEvent) {
|
||||
EnableEffect(province.Id, weatherEvent);
|
||||
_activeEffects[province.Id] = weatherEvent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ProvinceEvent.SealedValueOneofCase GetPriorityWeatherEvent(ProvinceView province) {
|
||||
foreach (var evt in province.KnownEvents) {
|
||||
if (evt.SealedValueCase == ProvinceEvent.SealedValueOneofCase.BlizzardEvent) {
|
||||
return ProvinceEvent.SealedValueOneofCase.BlizzardEvent;
|
||||
}
|
||||
}
|
||||
foreach (var evt in province.KnownEvents) {
|
||||
if (evt.SealedValueCase == ProvinceEvent.SealedValueOneofCase.FloodEvent) {
|
||||
return ProvinceEvent.SealedValueOneofCase.FloodEvent;
|
||||
}
|
||||
}
|
||||
foreach (var evt in province.KnownEvents) {
|
||||
if (evt.SealedValueCase == ProvinceEvent.SealedValueOneofCase.DroughtEvent) {
|
||||
return ProvinceEvent.SealedValueOneofCase.DroughtEvent;
|
||||
}
|
||||
}
|
||||
return ProvinceEvent.SealedValueOneofCase.None;
|
||||
}
|
||||
|
||||
private bool HasWeatherEvent(
|
||||
ProvinceView province,
|
||||
ProvinceEvent.SealedValueOneofCase eventType) {
|
||||
foreach (var evt in province.KnownEvents) {
|
||||
if (evt.SealedValueCase == eventType) { return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private RawImage GetProvinceImage(ProvinceId provinceId) {
|
||||
if (mapController?.provinceImagesList == null || provinceId < 1 ||
|
||||
provinceId > mapController.provinceImagesList.Count) {
|
||||
return null;
|
||||
}
|
||||
return mapController.provinceImagesList[provinceId - 1];
|
||||
}
|
||||
|
||||
private void EnableEffect(
|
||||
ProvinceId provinceId,
|
||||
ProvinceEvent.SealedValueOneofCase eventType) {
|
||||
var provinceImage = GetProvinceImage(provinceId);
|
||||
if (provinceImage == null) { return; }
|
||||
|
||||
DisableEffect(provinceId);
|
||||
|
||||
Material baseMat = eventType switch {
|
||||
ProvinceEvent.SealedValueOneofCase.BlizzardEvent => blizzardMaterial,
|
||||
ProvinceEvent.SealedValueOneofCase.FloodEvent => floodMaterial,
|
||||
ProvinceEvent.SealedValueOneofCase.DroughtEvent => droughtMaterial,
|
||||
_ => null
|
||||
};
|
||||
if (baseMat == null) { return; }
|
||||
|
||||
var overlayGO = new GameObject($"WeatherOverlay_{provinceId}");
|
||||
overlayGO.transform.SetParent(provinceImage.transform, false);
|
||||
|
||||
var rect = overlayGO.AddComponent<RectTransform>();
|
||||
rect.anchorMin = Vector2.zero;
|
||||
rect.anchorMax = Vector2.one;
|
||||
rect.offsetMin = Vector2.zero;
|
||||
rect.offsetMax = Vector2.zero;
|
||||
|
||||
var overlay = overlayGO.AddComponent<RawImage>();
|
||||
overlay.raycastTarget = false;
|
||||
|
||||
var mat = new Material(baseMat);
|
||||
var provinceMaterial = provinceImage.material;
|
||||
if (provinceMaterial != null && provinceMaterial.HasProperty("_Mask")) {
|
||||
mat.SetTexture("_Mask", provinceMaterial.GetTexture("_Mask"));
|
||||
}
|
||||
|
||||
overlay.material = mat;
|
||||
_overlayObjects[provinceId] = overlayGO;
|
||||
_instancedMaterials[provinceId] = mat;
|
||||
}
|
||||
|
||||
private void DisableEffect(ProvinceId provinceId) {
|
||||
if (_overlayObjects.TryGetValue(provinceId, out var overlayGO)) {
|
||||
Destroy(overlayGO);
|
||||
_overlayObjects.Remove(provinceId);
|
||||
}
|
||||
|
||||
if (_instancedMaterials.TryGetValue(provinceId, out var mat)) {
|
||||
Destroy(mat);
|
||||
_instancedMaterials.Remove(provinceId);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDestroy() {
|
||||
foreach (var go in _overlayObjects.Values) {
|
||||
if (go != null) { Destroy(go); }
|
||||
}
|
||||
_overlayObjects.Clear();
|
||||
|
||||
foreach (var mat in _instancedMaterials.Values) {
|
||||
if (mat != null) { Destroy(mat); }
|
||||
}
|
||||
_instancedMaterials.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 95b03faf2696848848e7438a9058e721
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 87c63ee542edd4eb8a788e48bd9d8ac8
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
Shader "Eagle/ProvinceWeather"
|
||||
{
|
||||
Properties
|
||||
{
|
||||
_Mask ("Province Mask", 2D) = "white" {}
|
||||
_MainTex ("Effect Texture", 2D) = "white" {}
|
||||
_TintColor ("Tint Color", Color) = (1,1,1,0.5)
|
||||
_Cutoff ("Alpha Cutoff", Range(0,1)) = 0.1
|
||||
|
||||
// Animation parameters
|
||||
_ScrollSpeed ("Scroll Speed", Vector) = (0.1, -0.2, 0, 0)
|
||||
_DistortionStrength ("Distortion", Range(0,0.1)) = 0
|
||||
_DistortionSpeed ("Distortion Speed", Float) = 1.0
|
||||
}
|
||||
SubShader
|
||||
{
|
||||
Tags {"Queue"="Transparent+1" "RenderType"="Transparent"}
|
||||
Blend SrcAlpha OneMinusSrcAlpha
|
||||
ZWrite Off
|
||||
|
||||
Pass
|
||||
{
|
||||
CGPROGRAM
|
||||
#pragma vertex vert
|
||||
#pragma fragment frag
|
||||
#include "UnityCG.cginc"
|
||||
|
||||
sampler2D _Mask;
|
||||
sampler2D _MainTex;
|
||||
float4 _MainTex_ST;
|
||||
float4 _TintColor;
|
||||
float _Cutoff;
|
||||
float4 _ScrollSpeed;
|
||||
float _DistortionStrength;
|
||||
float _DistortionSpeed;
|
||||
|
||||
struct appdata
|
||||
{
|
||||
float4 vertex : POSITION;
|
||||
float2 uv : TEXCOORD0;
|
||||
};
|
||||
|
||||
struct v2f
|
||||
{
|
||||
float4 pos : SV_POSITION;
|
||||
float2 uv : TEXCOORD0;
|
||||
float2 effectUV : TEXCOORD1;
|
||||
};
|
||||
|
||||
v2f vert(appdata v)
|
||||
{
|
||||
v2f o;
|
||||
o.pos = UnityObjectToClipPos(v.vertex);
|
||||
o.uv = v.uv;
|
||||
o.effectUV = TRANSFORM_TEX(v.uv, _MainTex);
|
||||
return o;
|
||||
}
|
||||
|
||||
float4 frag(v2f i) : SV_Target
|
||||
{
|
||||
// Sample mask
|
||||
float mask = tex2D(_Mask, i.uv).a;
|
||||
if (mask < _Cutoff)
|
||||
discard;
|
||||
|
||||
// Animated UV with optional distortion
|
||||
float2 uv = i.effectUV;
|
||||
uv += _ScrollSpeed.xy * _Time.y;
|
||||
|
||||
// Add wave distortion for flood effect
|
||||
if (_DistortionStrength > 0)
|
||||
{
|
||||
uv.x += sin(i.uv.y * 20 + _Time.y * _DistortionSpeed) * _DistortionStrength;
|
||||
uv.y += cos(i.uv.x * 20 + _Time.y * _DistortionSpeed) * _DistortionStrength;
|
||||
}
|
||||
|
||||
float4 effect = tex2D(_MainTex, uv);
|
||||
float4 result = effect * _TintColor;
|
||||
result.a *= mask;
|
||||
|
||||
return result;
|
||||
}
|
||||
ENDCG
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aba9e838913ad4571947526e18d5f748
|
||||
ShaderImporter:
|
||||
externalObjects: {}
|
||||
defaultTextures: []
|
||||
nonModifiableTextures: []
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+1
-1
@@ -3405,7 +3405,7 @@ MonoBehaviour:
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Texture: {fileID: 2800000, guid: e4bb37b7e56df4d1ab5e894387c9eb26, type: 3}
|
||||
m_Texture: {fileID: 2800000, guid: 2189208f745b6864c918942b534d0648, type: 3}
|
||||
m_UVRect:
|
||||
serializedVersion: 2
|
||||
x: 0
|
||||
|
||||
+38
-6
@@ -71,6 +71,7 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
text: Festival
|
||||
longPressThreshold: 0.4
|
||||
--- !u!1 &1174684494659827133
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -346,6 +347,7 @@ MonoBehaviour:
|
||||
m_VerticalAlignment: 512
|
||||
m_textAlignment: 65535
|
||||
m_characterSpacing: 0
|
||||
m_characterHorizontalScale: 1
|
||||
m_wordSpacing: 0
|
||||
m_lineSpacing: 0
|
||||
m_lineSpacingMax: 0
|
||||
@@ -412,6 +414,7 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
text: Food
|
||||
longPressThreshold: 0.4
|
||||
--- !u!1 &1958143429518513032
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -492,6 +495,7 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
text: Drought
|
||||
longPressThreshold: 0.4
|
||||
--- !u!1 &2559737193583365765
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -598,6 +602,7 @@ MonoBehaviour:
|
||||
m_VerticalAlignment: 512
|
||||
m_textAlignment: 65535
|
||||
m_characterSpacing: 0
|
||||
m_characterHorizontalScale: 1
|
||||
m_wordSpacing: 0
|
||||
m_lineSpacing: 0
|
||||
m_lineSpacingMax: 0
|
||||
@@ -664,6 +669,7 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
text: Agriculture
|
||||
longPressThreshold: 0.4
|
||||
--- !u!1 &3590529495957466971
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -772,6 +778,7 @@ MonoBehaviour:
|
||||
m_VerticalAlignment: 512
|
||||
m_textAlignment: 65535
|
||||
m_characterSpacing: 0
|
||||
m_characterHorizontalScale: 1
|
||||
m_wordSpacing: 0
|
||||
m_lineSpacing: 0
|
||||
m_lineSpacingMax: 0
|
||||
@@ -838,6 +845,7 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
text: Support
|
||||
longPressThreshold: 0.4
|
||||
--- !u!1 &3846196789171235436
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -946,6 +954,7 @@ MonoBehaviour:
|
||||
m_VerticalAlignment: 512
|
||||
m_textAlignment: 65535
|
||||
m_characterSpacing: 0
|
||||
m_characterHorizontalScale: 1
|
||||
m_wordSpacing: 0
|
||||
m_lineSpacing: 0
|
||||
m_lineSpacingMax: 0
|
||||
@@ -992,11 +1001,11 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: 158
|
||||
m_MinWidth: 100
|
||||
m_MinHeight: 14
|
||||
m_PreferredWidth: 158
|
||||
m_PreferredWidth: 100
|
||||
m_PreferredHeight: 14
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleWidth: 0
|
||||
m_FlexibleHeight: 1
|
||||
m_LayoutPriority: 1
|
||||
--- !u!114 &1213491477793146002
|
||||
@@ -1012,6 +1021,7 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
text: Province Name
|
||||
longPressThreshold: 0.4
|
||||
--- !u!1 &4024652214120210793
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -1077,7 +1087,7 @@ MonoBehaviour:
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Texture: {fileID: 2800000, guid: e4bb37b7e56df4d1ab5e894387c9eb26, type: 3}
|
||||
m_Texture: {fileID: 2800000, guid: 2189208f745b6864c918942b534d0648, type: 3}
|
||||
m_UVRect:
|
||||
serializedVersion: 2
|
||||
x: 0
|
||||
@@ -1192,14 +1202,15 @@ MonoBehaviour:
|
||||
m_VerticalAlignment: 512
|
||||
m_textAlignment: 65535
|
||||
m_characterSpacing: 0
|
||||
m_characterHorizontalScale: 1
|
||||
m_wordSpacing: 0
|
||||
m_lineSpacing: 0
|
||||
m_lineSpacingMax: 0
|
||||
m_paragraphSpacing: 0
|
||||
m_charWidthMaxAdj: 0
|
||||
m_TextWrappingMode: 1
|
||||
m_TextWrappingMode: 0
|
||||
m_wordWrappingRatios: 0.4
|
||||
m_overflowMode: 0
|
||||
m_overflowMode: 1
|
||||
m_linkedTextComponent: {fileID: 0}
|
||||
parentLinkedComponent: {fileID: 0}
|
||||
m_enableKerning: 1
|
||||
@@ -1258,6 +1269,7 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
text: Ruler
|
||||
longPressThreshold: 0.4
|
||||
--- !u!1 &4941871568980388328
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -1364,6 +1376,7 @@ MonoBehaviour:
|
||||
m_VerticalAlignment: 512
|
||||
m_textAlignment: 65535
|
||||
m_characterSpacing: 0
|
||||
m_characterHorizontalScale: 1
|
||||
m_wordSpacing: 0
|
||||
m_lineSpacing: 0
|
||||
m_lineSpacingMax: 0
|
||||
@@ -1430,6 +1443,7 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
text: Troops
|
||||
longPressThreshold: 0.4
|
||||
--- !u!1 &4945836044076577854
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -1853,6 +1867,7 @@ MonoBehaviour:
|
||||
m_VerticalAlignment: 512
|
||||
m_textAlignment: 65535
|
||||
m_characterSpacing: 0
|
||||
m_characterHorizontalScale: 1
|
||||
m_wordSpacing: 0
|
||||
m_lineSpacing: 0
|
||||
m_lineSpacingMax: 0
|
||||
@@ -1919,6 +1934,7 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
text: Infrastructure
|
||||
longPressThreshold: 0.4
|
||||
--- !u!1 &6258317720034698962
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -2025,6 +2041,7 @@ MonoBehaviour:
|
||||
m_VerticalAlignment: 512
|
||||
m_textAlignment: 65535
|
||||
m_characterSpacing: 0
|
||||
m_characterHorizontalScale: 1
|
||||
m_wordSpacing: 0
|
||||
m_lineSpacing: 0
|
||||
m_lineSpacingMax: 0
|
||||
@@ -2091,6 +2108,7 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
text: Battalions
|
||||
longPressThreshold: 0.4
|
||||
--- !u!1 &6728903281829147639
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -2234,6 +2252,7 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
text: Flood
|
||||
longPressThreshold: 0.4
|
||||
--- !u!1 &6894588085672347487
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -2305,6 +2324,7 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
text: Epidemic
|
||||
longPressThreshold: 0.4
|
||||
--- !u!1 &7228097634305426837
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -2376,6 +2396,7 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
text: Blizzard
|
||||
longPressThreshold: 0.4
|
||||
--- !u!1 &7266789630680255860
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -2626,6 +2647,7 @@ MonoBehaviour:
|
||||
m_VerticalAlignment: 512
|
||||
m_textAlignment: 65535
|
||||
m_characterSpacing: 0
|
||||
m_characterHorizontalScale: 1
|
||||
m_wordSpacing: 0
|
||||
m_lineSpacing: 0
|
||||
m_lineSpacingMax: 0
|
||||
@@ -2692,6 +2714,7 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
text: Gold
|
||||
longPressThreshold: 0.4
|
||||
--- !u!1 &7635206205023075165
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -2798,6 +2821,7 @@ MonoBehaviour:
|
||||
m_VerticalAlignment: 512
|
||||
m_textAlignment: 65535
|
||||
m_characterSpacing: 0
|
||||
m_characterHorizontalScale: 1
|
||||
m_wordSpacing: 0
|
||||
m_lineSpacing: 0
|
||||
m_lineSpacingMax: 0
|
||||
@@ -2864,6 +2888,7 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
text: Castles
|
||||
longPressThreshold: 0.4
|
||||
--- !u!1 &7814791637563432440
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -2970,6 +2995,7 @@ MonoBehaviour:
|
||||
m_VerticalAlignment: 512
|
||||
m_textAlignment: 65535
|
||||
m_characterSpacing: 0
|
||||
m_characterHorizontalScale: 1
|
||||
m_wordSpacing: 0
|
||||
m_lineSpacing: 0
|
||||
m_lineSpacingMax: 0
|
||||
@@ -3036,6 +3062,7 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
text: Economy
|
||||
longPressThreshold: 0.4
|
||||
--- !u!1 &7862705161045332665
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -3116,6 +3143,7 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
text: Beasts
|
||||
longPressThreshold: 0.4
|
||||
--- !u!1 &7991708085543724938
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -3222,6 +3250,7 @@ MonoBehaviour:
|
||||
m_VerticalAlignment: 512
|
||||
m_textAlignment: 65535
|
||||
m_characterSpacing: 0
|
||||
m_characterHorizontalScale: 1
|
||||
m_wordSpacing: 0
|
||||
m_lineSpacing: 0
|
||||
m_lineSpacingMax: 0
|
||||
@@ -3288,6 +3317,7 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
text: Free Heroes
|
||||
longPressThreshold: 0.4
|
||||
--- !u!1 &9001699000960796923
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -3394,6 +3424,7 @@ MonoBehaviour:
|
||||
m_VerticalAlignment: 512
|
||||
m_textAlignment: 65535
|
||||
m_characterSpacing: 0
|
||||
m_characterHorizontalScale: 1
|
||||
m_wordSpacing: 0
|
||||
m_lineSpacing: 0
|
||||
m_lineSpacingMax: 0
|
||||
@@ -3460,3 +3491,4 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
text: Commanders
|
||||
longPressThreshold: 0.4
|
||||
|
||||
+56
@@ -136,6 +136,17 @@ namespace eagle {
|
||||
model);
|
||||
break;
|
||||
|
||||
case SealedValueOneofCase.RescueImprisonedLeaderQuest:
|
||||
var rescueDetails = quest.Details.RescueImprisonedLeaderQuest;
|
||||
var imprisoningFactionName =
|
||||
model.FactionName(rescueDetails.ImprisoningFactionId);
|
||||
SetDynamicHeroQuestText(
|
||||
rescueDetails.ImprisonedHeroId,
|
||||
(heroName) => $"Rescue {heroName} from {imprisoningFactionName}",
|
||||
$"Rescue a hero from {imprisoningFactionName}",
|
||||
model);
|
||||
break;
|
||||
|
||||
default:
|
||||
// For all other quests, use the static method
|
||||
questLabel.text = ShortQuestString(quest, model);
|
||||
@@ -177,6 +188,13 @@ namespace eagle {
|
||||
}
|
||||
}
|
||||
|
||||
static string GetHeroName(IGameModel model, int heroId) {
|
||||
if (!model.Heroes.TryGetValue(heroId, out var hero)) { return "a hero"; }
|
||||
var textEntry = ClientTextProvider.Provider.GetTextEntry(hero.NameTextId);
|
||||
return textEntry != null && !string.IsNullOrEmpty(textEntry.Text) ? textEntry.Text
|
||||
: "a hero";
|
||||
}
|
||||
|
||||
static string ShortQuestString(Quest quest, IGameModel model) {
|
||||
if (quest == null) { return $"There is nothing you can do"; }
|
||||
|
||||
@@ -267,6 +285,44 @@ namespace eagle {
|
||||
currentTruceString;
|
||||
}
|
||||
|
||||
case SealedValueOneofCase.RescueImprisonedLeaderQuest: {
|
||||
// This case is handled by SetQuestText with dynamic updates, this is fallback
|
||||
// only
|
||||
var details = quest.Details.RescueImprisonedLeaderQuest;
|
||||
var heroName = GetHeroName(model, details.ImprisonedHeroId);
|
||||
return $"Rescue {heroName} from {model.FactionName(details.ImprisoningFactionId)}";
|
||||
}
|
||||
|
||||
case SealedValueOneofCase.ExpandToProvincesQuest: {
|
||||
var targetCount = quest.Details.ExpandToProvincesQuest.TargetProvinceCount;
|
||||
// A province is "developed" when support >= 40 (MinSupportForTaxes)
|
||||
var currentCount =
|
||||
model.PlayerId.HasValue
|
||||
? model.ProvincesForFaction(model.PlayerId.Value)
|
||||
.Count(pid => model.Provinces[pid]
|
||||
.FullInfo?.Support?.Stat >= 40)
|
||||
: 0;
|
||||
return $"Have {targetCount} developed provinces (currently {currentCount})";
|
||||
}
|
||||
|
||||
case SealedValueOneofCase.TotalDevelopmentQuest: {
|
||||
var details = quest.Details.TotalDevelopmentQuest;
|
||||
var province = model.Provinces[details.ProvinceId];
|
||||
var target = (int)Math.Ceiling(details.DesiredTotalDevelopment);
|
||||
var info = province.FullInfo;
|
||||
var current =
|
||||
(int)((info.Economy - info.EconomyDevastation) +
|
||||
(info.Agriculture - info.AgricultureDevastation) +
|
||||
(info.Infrastructure - info.InfrastructureDevastation));
|
||||
return $"Raise total development to {target} in {province.Name} (currently {current})";
|
||||
}
|
||||
|
||||
case SealedValueOneofCase.SuppressRiotByForceQuest:
|
||||
return "Suppress a riot by force";
|
||||
|
||||
case SealedValueOneofCase.FightBeastsAloneQuest:
|
||||
return "Have a hero fight beasts alone";
|
||||
|
||||
default:
|
||||
return $"Crosby hasn't gotten to quest of type {quest.Details.SealedValueCase} yet";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d786929811f564787855dc24bb8e55b2
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
BIN
Binary file not shown.
+29
-27
@@ -1,9 +1,9 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 65d6ab023603741f0aec6308079be48b
|
||||
guid: 29d8048ef082c49c98677228ff51f7bd
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 11
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 1
|
||||
@@ -20,9 +20,12 @@ TextureImporter:
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMipmapLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
@@ -31,12 +34,12 @@ TextureImporter:
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: -1
|
||||
aniso: -1
|
||||
mipBias: -100
|
||||
wrapU: -1
|
||||
wrapV: -1
|
||||
wrapW: -1
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 0
|
||||
wrapV: 0
|
||||
wrapW: 0
|
||||
nPOTScale: 1
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
@@ -49,17 +52,22 @@ TextureImporter:
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 1
|
||||
alphaIsTransparency: 0
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 0
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 3
|
||||
- serializedVersion: 4
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
@@ -69,9 +77,10 @@ TextureImporter:
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
- serializedVersion: 4
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
@@ -81,22 +90,11 @@ TextureImporter:
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: iPhone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Android
|
||||
- serializedVersion: 4
|
||||
buildTarget: iOS
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
@@ -105,12 +103,14 @@ TextureImporter:
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID:
|
||||
@@ -120,9 +120,11 @@ TextureImporter:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spritePackingTag:
|
||||
spriteCustomMetadata:
|
||||
entries: []
|
||||
nameFileIdTable: {}
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
pSDShowRemoveMatteOption: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
BIN
Binary file not shown.
+29
-27
@@ -1,9 +1,9 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 297854d016fdc49d9b9d6c7904b00ac4
|
||||
guid: 8dd57c94902f24f51b5aa9c968d75636
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 11
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 1
|
||||
@@ -20,9 +20,12 @@ TextureImporter:
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMipmapLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
@@ -31,12 +34,12 @@ TextureImporter:
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: -1
|
||||
aniso: -1
|
||||
mipBias: -100
|
||||
wrapU: -1
|
||||
wrapV: -1
|
||||
wrapW: -1
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 0
|
||||
wrapV: 0
|
||||
wrapW: 0
|
||||
nPOTScale: 1
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
@@ -49,17 +52,22 @@ TextureImporter:
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 1
|
||||
alphaIsTransparency: 0
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 0
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 3
|
||||
- serializedVersion: 4
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
@@ -69,9 +77,10 @@ TextureImporter:
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
- serializedVersion: 4
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
@@ -81,22 +90,11 @@ TextureImporter:
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: iPhone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Android
|
||||
- serializedVersion: 4
|
||||
buildTarget: iOS
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
@@ -105,12 +103,14 @@ TextureImporter:
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID:
|
||||
@@ -120,9 +120,11 @@ TextureImporter:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spritePackingTag:
|
||||
spriteCustomMetadata:
|
||||
entries: []
|
||||
nameFileIdTable: {}
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
pSDShowRemoveMatteOption: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
BIN
Binary file not shown.
+29
-27
@@ -1,9 +1,9 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8a8ef7ae2668e41e78c9eaae1d12b76b
|
||||
guid: 7ce4f51bbb08f43108219f9981e25ced
|
||||
TextureImporter:
|
||||
internalIDToNameTable: []
|
||||
externalObjects: {}
|
||||
serializedVersion: 11
|
||||
serializedVersion: 13
|
||||
mipmaps:
|
||||
mipMapMode: 0
|
||||
enableMipMap: 1
|
||||
@@ -20,9 +20,12 @@ TextureImporter:
|
||||
externalNormalMap: 0
|
||||
heightScale: 0.25
|
||||
normalMapFilter: 0
|
||||
flipGreenChannel: 0
|
||||
isReadable: 0
|
||||
streamingMipmaps: 0
|
||||
streamingMipmapsPriority: 0
|
||||
vTOnly: 0
|
||||
ignoreMipmapLimit: 0
|
||||
grayScaleToAlpha: 0
|
||||
generateCubemap: 6
|
||||
cubemapConvolution: 0
|
||||
@@ -31,12 +34,12 @@ TextureImporter:
|
||||
maxTextureSize: 2048
|
||||
textureSettings:
|
||||
serializedVersion: 2
|
||||
filterMode: -1
|
||||
aniso: -1
|
||||
mipBias: -100
|
||||
wrapU: -1
|
||||
wrapV: -1
|
||||
wrapW: -1
|
||||
filterMode: 1
|
||||
aniso: 1
|
||||
mipBias: 0
|
||||
wrapU: 0
|
||||
wrapV: 0
|
||||
wrapW: 0
|
||||
nPOTScale: 1
|
||||
lightmap: 0
|
||||
compressionQuality: 50
|
||||
@@ -49,17 +52,22 @@ TextureImporter:
|
||||
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
|
||||
spriteGenerateFallbackPhysicsShape: 1
|
||||
alphaUsage: 1
|
||||
alphaIsTransparency: 1
|
||||
alphaIsTransparency: 0
|
||||
spriteTessellationDetail: -1
|
||||
textureType: 0
|
||||
textureShape: 1
|
||||
singleChannelComponent: 0
|
||||
flipbookRows: 1
|
||||
flipbookColumns: 1
|
||||
maxTextureSizeSet: 0
|
||||
compressionQualitySet: 0
|
||||
textureFormatSet: 0
|
||||
ignorePngGamma: 0
|
||||
applyGammaDecoding: 0
|
||||
swizzle: 50462976
|
||||
cookieLightType: 0
|
||||
platformSettings:
|
||||
- serializedVersion: 3
|
||||
- serializedVersion: 4
|
||||
buildTarget: DefaultTexturePlatform
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
@@ -69,9 +77,10 @@ TextureImporter:
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
- serializedVersion: 4
|
||||
buildTarget: Standalone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
@@ -81,22 +90,11 @@ TextureImporter:
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: iPhone
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
textureCompression: 1
|
||||
compressionQuality: 50
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
- serializedVersion: 3
|
||||
buildTarget: Android
|
||||
- serializedVersion: 4
|
||||
buildTarget: iOS
|
||||
maxTextureSize: 2048
|
||||
resizeAlgorithm: 0
|
||||
textureFormat: -1
|
||||
@@ -105,12 +103,14 @@ TextureImporter:
|
||||
crunchedCompression: 0
|
||||
allowsAlphaSplitting: 0
|
||||
overridden: 0
|
||||
ignorePlatformSupport: 0
|
||||
androidETC2FallbackOverride: 0
|
||||
forceMaximumCompressionQuality_BC6H_BC7: 0
|
||||
spriteSheet:
|
||||
serializedVersion: 2
|
||||
sprites: []
|
||||
outline: []
|
||||
customData:
|
||||
physicsShape: []
|
||||
bones: []
|
||||
spriteID:
|
||||
@@ -120,9 +120,11 @@ TextureImporter:
|
||||
edges: []
|
||||
weights: []
|
||||
secondaryTextures: []
|
||||
spritePackingTag:
|
||||
spriteCustomMetadata:
|
||||
entries: []
|
||||
nameFileIdTable: {}
|
||||
mipmapLimitGroupName:
|
||||
pSDRemoveMatte: 0
|
||||
pSDShowRemoveMatteOption: 0
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 74d29d3f6255d41c1bcff10a1fc06d10
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!21 &2100000
|
||||
Material:
|
||||
serializedVersion: 8
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_Name: BlizzardEffect
|
||||
m_Shader: {fileID: 4800000, guid: aba9e838913ad4571947526e18d5f748, type: 3}
|
||||
m_Parent: {fileID: 0}
|
||||
m_ModifiedSerializedProperties: 0
|
||||
m_ValidKeywords: []
|
||||
m_InvalidKeywords: []
|
||||
m_LightmapFlags: 4
|
||||
m_EnableInstancingVariants: 0
|
||||
m_DoubleSidedGI: 0
|
||||
m_CustomRenderQueue: -1
|
||||
stringTagMap: {}
|
||||
disabledShaderPasses: []
|
||||
m_LockedProperties:
|
||||
m_SavedProperties:
|
||||
serializedVersion: 3
|
||||
m_TexEnvs:
|
||||
- _BumpMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _DetailAlbedoMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _DetailMask:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _DetailNormalMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _EffectTex:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _EmissionMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _MainTex:
|
||||
m_Texture: {fileID: 2800000, guid: 8dd57c94902f24f51b5aa9c968d75636, type: 3}
|
||||
m_Scale: {x: 10, y: 10}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _Mask:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _MetallicGlossMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _OcclusionMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _ParallaxMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
m_Ints: []
|
||||
m_Floats:
|
||||
- _BumpScale: 1
|
||||
- _Cutoff: 0.5
|
||||
- _DetailNormalMapScale: 1
|
||||
- _DistortionSpeed: 1
|
||||
- _DistortionStrength: 0
|
||||
- _DstBlend: 0
|
||||
- _GlossMapScale: 1
|
||||
- _Glossiness: 0.5
|
||||
- _GlossyReflections: 1
|
||||
- _Metallic: 0
|
||||
- _Mode: 0
|
||||
- _OcclusionStrength: 1
|
||||
- _Parallax: 0.02
|
||||
- _SmoothnessTextureChannel: 0
|
||||
- _SpecularHighlights: 1
|
||||
- _SrcBlend: 1
|
||||
- _UVSec: 0
|
||||
- _ZWrite: 1
|
||||
m_Colors:
|
||||
- _Color: {r: 1, g: 1, b: 1, a: 1}
|
||||
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
|
||||
- _ScrollSpeed: {r: -0.05, g: 0.15, b: 0, a: 0}
|
||||
- _TintColor: {r: 1, g: 1, b: 1, a: 0.4}
|
||||
m_BuildTextureStacks: []
|
||||
m_AllowLocking: 1
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 509ffb34793d94db491eef0dd0f65cd5
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 2100000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!21 &2100000
|
||||
Material:
|
||||
serializedVersion: 8
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_Name: DroughtEffect
|
||||
m_Shader: {fileID: 4800000, guid: aba9e838913ad4571947526e18d5f748, type: 3}
|
||||
m_Parent: {fileID: 0}
|
||||
m_ModifiedSerializedProperties: 0
|
||||
m_ValidKeywords: []
|
||||
m_InvalidKeywords: []
|
||||
m_LightmapFlags: 4
|
||||
m_EnableInstancingVariants: 0
|
||||
m_DoubleSidedGI: 0
|
||||
m_CustomRenderQueue: -1
|
||||
stringTagMap: {}
|
||||
disabledShaderPasses: []
|
||||
m_LockedProperties:
|
||||
m_SavedProperties:
|
||||
serializedVersion: 3
|
||||
m_TexEnvs:
|
||||
- _BumpMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _DetailAlbedoMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _DetailMask:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _DetailNormalMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _EffectTex:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _EmissionMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _MainTex:
|
||||
m_Texture: {fileID: 2800000, guid: 29d8048ef082c49c98677228ff51f7bd, type: 3}
|
||||
m_Scale: {x: 10, y: 10}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _Mask:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _MetallicGlossMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _OcclusionMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _ParallaxMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
m_Ints: []
|
||||
m_Floats:
|
||||
- _BumpScale: 1
|
||||
- _Cutoff: 0.5
|
||||
- _DetailNormalMapScale: 1
|
||||
- _DistortionSpeed: 0.5
|
||||
- _DistortionStrength: 0.005
|
||||
- _DstBlend: 0
|
||||
- _GlossMapScale: 1
|
||||
- _Glossiness: 0.5
|
||||
- _GlossyReflections: 1
|
||||
- _Metallic: 0
|
||||
- _Mode: 0
|
||||
- _OcclusionStrength: 1
|
||||
- _Parallax: 0.02
|
||||
- _SmoothnessTextureChannel: 0
|
||||
- _SpecularHighlights: 1
|
||||
- _SrcBlend: 1
|
||||
- _UVSec: 0
|
||||
- _ZWrite: 1
|
||||
m_Colors:
|
||||
- _Color: {r: 1, g: 1, b: 1, a: 1}
|
||||
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
|
||||
- _ScrollSpeed: {r: 0, g: 0, b: 0, a: 0}
|
||||
- _TintColor: {r: 0.6, g: 0.4, b: 0.2, a: 0.2509804}
|
||||
m_BuildTextureStacks: []
|
||||
m_AllowLocking: 1
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ee1a9dbcdcf80468788998759ac3e3a6
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 2100000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,96 @@
|
||||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!21 &2100000
|
||||
Material:
|
||||
serializedVersion: 8
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_Name: FloodEffect
|
||||
m_Shader: {fileID: 4800000, guid: aba9e838913ad4571947526e18d5f748, type: 3}
|
||||
m_Parent: {fileID: 0}
|
||||
m_ModifiedSerializedProperties: 0
|
||||
m_ValidKeywords: []
|
||||
m_InvalidKeywords: []
|
||||
m_LightmapFlags: 4
|
||||
m_EnableInstancingVariants: 0
|
||||
m_DoubleSidedGI: 0
|
||||
m_CustomRenderQueue: -1
|
||||
stringTagMap: {}
|
||||
disabledShaderPasses: []
|
||||
m_LockedProperties:
|
||||
m_SavedProperties:
|
||||
serializedVersion: 3
|
||||
m_TexEnvs:
|
||||
- _BumpMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _DetailAlbedoMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _DetailMask:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _DetailNormalMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _EffectTex:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _EmissionMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _MainTex:
|
||||
m_Texture: {fileID: 2800000, guid: 7ce4f51bbb08f43108219f9981e25ced, type: 3}
|
||||
m_Scale: {x: 40, y: 40}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _Mask:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _MetallicGlossMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _OcclusionMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
- _ParallaxMap:
|
||||
m_Texture: {fileID: 0}
|
||||
m_Scale: {x: 1, y: 1}
|
||||
m_Offset: {x: 0, y: 0}
|
||||
m_Ints: []
|
||||
m_Floats:
|
||||
- _BumpScale: 1
|
||||
- _Cutoff: 0.5
|
||||
- _DetailNormalMapScale: 1
|
||||
- _DistortionSpeed: 5
|
||||
- _DistortionStrength: 0.1
|
||||
- _DstBlend: 0
|
||||
- _GlossMapScale: 1
|
||||
- _Glossiness: 0.5
|
||||
- _GlossyReflections: 1
|
||||
- _Metallic: 0
|
||||
- _Mode: 0
|
||||
- _OcclusionStrength: 1
|
||||
- _Parallax: 0.02
|
||||
- _SmoothnessTextureChannel: 0
|
||||
- _SpecularHighlights: 1
|
||||
- _SrcBlend: 1
|
||||
- _UVSec: 0
|
||||
- _ZWrite: 1
|
||||
m_Colors:
|
||||
- _Color: {r: 1, g: 1, b: 1, a: 1}
|
||||
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
|
||||
- _ScrollSpeed: {r: 0.1, g: 0, b: 0, a: 0}
|
||||
- _TintColor: {r: 0.3, g: 0.5, b: 0.8, a: 0.29803923}
|
||||
m_BuildTextureStacks: []
|
||||
m_AllowLocking: 1
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f813600ea057f47fea43246d1ad37e72
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 2100000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -133,6 +133,7 @@ public static class BuildScript {
|
||||
/// <summary>
|
||||
/// Builds Addressables and then the iOS player.
|
||||
/// Call via: -executeMethod BuildScript.BuildiOSPlayer
|
||||
/// Optional: -buildNumber <number> to set CFBundleVersion
|
||||
/// </summary>
|
||||
public static void BuildiOSPlayer() {
|
||||
BuildiOSAddressables();
|
||||
@@ -144,6 +145,15 @@ public static class BuildScript {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set build number if provided (required for unique TestFlight uploads)
|
||||
string buildNumber = GetBuildNumberArg();
|
||||
if (!string.IsNullOrEmpty(buildNumber)) {
|
||||
PlayerSettings.iOS.buildNumber = buildNumber;
|
||||
Debug.Log($"Set iOS build number to: {buildNumber}");
|
||||
} else {
|
||||
Debug.Log($"Using existing iOS build number: {PlayerSettings.iOS.buildNumber}");
|
||||
}
|
||||
|
||||
Debug.Log($"Building iOS player to: {buildPath}");
|
||||
|
||||
var options = new BuildPlayerOptions {
|
||||
@@ -179,4 +189,12 @@ public static class BuildScript {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GetBuildNumberArg() {
|
||||
var args = System.Environment.GetCommandLineArgs();
|
||||
for (int i = 0; i < args.Length - 1; i++) {
|
||||
if (args[i] == "-buildNumber") { return args[i + 1]; }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 04df2e7394da44f258040fe2fa230ab1
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 16c000a7ba9a34e95aee4e0a6783dcd6
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -10603,7 +10603,7 @@ MonoBehaviour:
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Texture: {fileID: 2800000, guid: 8a8ef7ae2668e41e78c9eaae1d12b76b, type: 3}
|
||||
m_Texture: {fileID: 2800000, guid: 2c8cc471ef7bfd2409974b6128642a22, type: 3}
|
||||
m_UVRect:
|
||||
serializedVersion: 2
|
||||
x: 0
|
||||
@@ -20218,7 +20218,7 @@ MonoBehaviour:
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Texture: {fileID: 2800000, guid: e4bb37b7e56df4d1ab5e894387c9eb26, type: 3}
|
||||
m_Texture: {fileID: 2800000, guid: 2189208f745b6864c918942b534d0648, type: 3}
|
||||
m_UVRect:
|
||||
serializedVersion: 2
|
||||
x: 0
|
||||
@@ -24129,7 +24129,7 @@ MonoBehaviour:
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Texture: {fileID: 2800000, guid: 9b27e24f2af7a436084d697f568bef8f, type: 3}
|
||||
m_Texture: {fileID: 2800000, guid: b7edeab8e9ac55d49a266f812d0ea8d4, type: 3}
|
||||
m_UVRect:
|
||||
serializedVersion: 2
|
||||
x: 0
|
||||
@@ -26891,7 +26891,7 @@ MonoBehaviour:
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Texture: {fileID: 2800000, guid: c804420ff739f4ad4b28e0459a58d2c1, type: 3}
|
||||
m_Texture: {fileID: 2800000, guid: dbabcdc1307aefb4bae749574b34316a, type: 3}
|
||||
m_UVRect:
|
||||
serializedVersion: 2
|
||||
x: 0
|
||||
@@ -28299,7 +28299,7 @@ MonoBehaviour:
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Texture: {fileID: 2800000, guid: ca456c8b4dafa49f8b513162ada0e9e3, type: 3}
|
||||
m_Texture: {fileID: 2800000, guid: 7ec94926d897644858bd8a23522e1787, type: 3}
|
||||
m_UVRect:
|
||||
serializedVersion: 2
|
||||
x: 0
|
||||
@@ -46148,7 +46148,7 @@ MonoBehaviour:
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Texture: {fileID: 2800000, guid: 65d6ab023603741f0aec6308079be48b, type: 3}
|
||||
m_Texture: {fileID: 2800000, guid: 2189208f745b6864c918942b534d0648, type: 3}
|
||||
m_UVRect:
|
||||
serializedVersion: 2
|
||||
x: 0
|
||||
|
||||
Binary file not shown.
+8
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 152b840e8b73b41f8a92fcc1398cc93c
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 112000000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user