mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 13:35:42 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31d5702f0b | ||
|
|
9612c2154c | ||
|
|
fe92f69ca9 |
@@ -5,10 +5,6 @@ common --ui_event_filters=-INFO
|
||||
|
||||
common --enable_bzlmod
|
||||
|
||||
# Use pre-built protoc binary instead of compiling from source
|
||||
common --incompatible_enable_proto_toolchain_resolution
|
||||
common --@com_google_protobuf//bazel/toolchains:prefer_prebuilt_protoc=true
|
||||
|
||||
# Don't use toolchains_llvm for the swift app build
|
||||
common:mactools --ignore_dev_dependency
|
||||
|
||||
@@ -30,28 +26,10 @@ common --host_cxxopt="--std=c++23"
|
||||
common --javacopt="-Xlint:-options"
|
||||
|
||||
# suppress warnings due to https://developer.apple.com/forums/thread/733317
|
||||
# Use host_linkopt for macOS-specific flags to avoid passing them to Linux cross-compilation
|
||||
common:macos --host_linkopt=-Wl,-no_warn_duplicate_libraries
|
||||
common --linkopt=-Wl
|
||||
common:macos --linkopt=-Wl,-no_warn_duplicate_libraries
|
||||
|
||||
# Workaround for grpc cf_event_engine build error on macOS with Bazel 7.x
|
||||
# See: https://github.com/grpc/grpc/issues/37619
|
||||
common:macos --features=-module_maps
|
||||
|
||||
# Pin DEVELOPER_DIR so the apple_cc_autoconf repo rule doesn't re-evaluate
|
||||
# when Xcode updates in-place. The sync script overrides this for mactools.
|
||||
common:macos --repo_env=DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||
|
||||
# Xcode config for mactools builds only. Generated by scripts/sync_bazel_xcode.sh.
|
||||
# Bakes the Xcode build version into action cache keys and sets DEVELOPER_DIR.
|
||||
try-import %workspace%/.bazelrc.xcode
|
||||
|
||||
common --java_language_version=25
|
||||
common --java_runtime_version=remotejdk_25
|
||||
common --tool_java_language_version=25
|
||||
common --tool_java_runtime_version=remotejdk_25
|
||||
|
||||
# Workspace status for build stamping (git commit, timestamp)
|
||||
# Only targets with stamp=1 will use these values; we intentionally
|
||||
# do NOT set "common --stamp" so non-stamped targets can share the
|
||||
# remote cache across CI workflows.
|
||||
common --workspace_status_command=tools/workspace_status.sh
|
||||
common --java_language_version=17
|
||||
common --java_runtime_version=remotejdk_17
|
||||
common --tool_java_language_version=17
|
||||
common --tool_java_runtime_version=remotejdk_17
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
8.5.1
|
||||
7.6.1
|
||||
|
||||
@@ -5,39 +5,5 @@
|
||||
*.tif filter=lfs diff=lfs merge=lfs -text
|
||||
*.bytes filter=lfs diff=lfs merge=lfs -text
|
||||
*.psd filter=lfs diff=lfs merge=lfs -text
|
||||
*.psb filter=lfs diff=lfs merge=lfs -text
|
||||
# PSB meta files contain bone/sprite import data and can be very large
|
||||
*.psb.meta filter=lfs diff=lfs merge=lfs -text
|
||||
*.ttf filter=lfs diff=lfs merge=lfs -text
|
||||
# Exclude pre-existing font files that were committed as blobs (not LFS pointers)
|
||||
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
|
||||
*.tga filter=lfs diff=lfs merge=lfs -text
|
||||
*.fbx filter=lfs diff=lfs merge=lfs -text
|
||||
*.FBX filter=lfs diff=lfs merge=lfs -text
|
||||
*.pdf filter=lfs diff=lfs merge=lfs -text
|
||||
# Third-party asset packs: track all files via LFS (never manually edited)
|
||||
src/main/csharp/**/DungeonMonsters2D/** filter=lfs diff=lfs merge=lfs -text
|
||||
src/main/csharp/**/Raccoon/** filter=lfs diff=lfs merge=lfs -text
|
||||
src/main/csharp/**/Polytope[[:space:]]Studio/** filter=lfs diff=lfs merge=lfs -text
|
||||
src/main/csharp/**/RRFreelance-Characters/** filter=lfs diff=lfs merge=lfs -text
|
||||
src/main/csharp/**/Dragon/** filter=lfs diff=lfs merge=lfs -text
|
||||
*.jpg filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
# Unity Smart Merge - use UnityYAMLMerge for Unity files
|
||||
# Requires configuring unityyamlmerge in ~/.gitconfig:
|
||||
# [merge]
|
||||
# tool = unityyamlmerge
|
||||
# [mergetool "unityyamlmerge"]
|
||||
# trustExitCode = false
|
||||
# cmd = '/Applications/Unity/Hub/Editor/*/Unity.app/Contents/Tools/UnityYAMLMerge' merge -p "$BASE" "$REMOTE" "$LOCAL" "$MERGED"
|
||||
*.unity merge=unityyamlmerge
|
||||
*.prefab merge=unityyamlmerge
|
||||
*.asset merge=unityyamlmerge
|
||||
*.mat merge=unityyamlmerge
|
||||
*.anim merge=unityyamlmerge
|
||||
*.controller merge=unityyamlmerge
|
||||
*.physicsMaterial2D merge=unityyamlmerge
|
||||
*.physicMaterial merge=unityyamlmerge
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
name: Artifact Storage Check
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run every 6 hours
|
||||
- cron: '0 */6 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check-storage:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check artifact storage size
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# Calculate total artifact storage
|
||||
total_bytes=$(gh api "repos/${{ github.repository }}/actions/artifacts" \
|
||||
--paginate -q '.artifacts[].size_in_bytes' | awk '{sum+=$1} END {print sum}')
|
||||
|
||||
total_mb=$((total_bytes / 1024 / 1024))
|
||||
echo "Total artifact storage: ${total_mb} MB"
|
||||
|
||||
# Fail if over 500MB
|
||||
if [ "$total_mb" -gt 500 ]; then
|
||||
echo "::error::Artifact storage is ${total_mb} MB, which exceeds the 500 MB threshold!"
|
||||
echo ""
|
||||
echo "Largest artifacts:"
|
||||
# Save to temp file to avoid SIGPIPE/broken pipe errors with head
|
||||
gh api "repos/${{ github.repository }}/actions/artifacts" \
|
||||
--paginate -q '.artifacts[] | "\(.size_in_bytes)\t\(.name)\t\(.created_at)"' > /tmp/artifacts.txt
|
||||
sort -rn /tmp/artifacts.txt | head -20 | \
|
||||
awk -F'\t' '{printf "%d MB\t%s\t%s\n", $1/1024/1024, $2, $3}'
|
||||
rm -f /tmp/artifacts.txt
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Storage is within acceptable limits."
|
||||
@@ -1,210 +0,0 @@
|
||||
name: Auth Service Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 'src/main/go/net/eagle0/authservice/**'
|
||||
- 'src/main/go/net/eagle0/authcli/**'
|
||||
- 'src/main/protobuf/net/eagle0/eagle/api/auth.proto'
|
||||
- 'src/main/protobuf/net/eagle0/eagle/api/admin/**'
|
||||
- 'src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto'
|
||||
- 'src/main/resources/net/eagle0/attributions.json'
|
||||
- 'ci/BUILD.bazel'
|
||||
- '.bazelrc'
|
||||
- '.github/workflows/auth_build.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push_images:
|
||||
description: 'Push images to container registry'
|
||||
required: true
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-auth:
|
||||
runs-on: [self-hosted, bazel]
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-auth.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Build Auth Server Docker image
|
||||
id: build-auth
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
# Build auth server image (Go binary has explicit goos/goarch in BUILD.bazel)
|
||||
# --stamp is needed for authservice x_defs (git commit, build time)
|
||||
bazel build --stamp //ci:auth_server_image
|
||||
|
||||
# Save the resolved path before any other bazel command changes bazel-bin symlink
|
||||
IMAGE_PATH=$(readlink -f bazel-bin/ci/auth_server_image)
|
||||
echo "Image path: $IMAGE_PATH"
|
||||
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to DigitalOcean Container Registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
run: |
|
||||
mkdir -p ~/.docker
|
||||
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
|
||||
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
mkdir -p .docker
|
||||
cp ~/.docker/config.json .docker/
|
||||
|
||||
- name: Push Auth image to DO registry
|
||||
id: push-auth
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DOCKER_CONFIG: ${{ github.workspace }}/.docker
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
AUTH_IMAGE="${{ steps.build-auth.outputs.image_path }}"
|
||||
echo "Using Auth image: $AUTH_IMAGE"
|
||||
|
||||
if [ -z "$AUTH_IMAGE" ] || [ ! -d "$AUTH_IMAGE" ]; then
|
||||
echo "ERROR: Auth image not found at: $AUTH_IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build the push target to get crane in runfiles
|
||||
bazel build //ci:auth_server_push
|
||||
|
||||
# Find crane binary in runfiles (path varies by Bazel version)
|
||||
RUNFILES="bazel-bin/ci/push_auth_server_push.sh.runfiles"
|
||||
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
|
||||
if [ -z "$CRANE" ] || [ ! -x "$CRANE" ]; then
|
||||
echo "ERROR: crane not found in runfiles"
|
||||
find "$RUNFILES" -name crane 2>/dev/null || echo "No crane found at all"
|
||||
exit 1
|
||||
fi
|
||||
echo "Using crane: $CRANE"
|
||||
|
||||
# Push with SHA tag
|
||||
GIT_SHA=$(git rev-parse --short=8 HEAD)
|
||||
IMAGE_TAG="registry.digitalocean.com/eagle0/auth-server:${GIT_SHA}"
|
||||
echo "Pushing auth image: $IMAGE_TAG"
|
||||
$CRANE push "$AUTH_IMAGE" "$IMAGE_TAG"
|
||||
|
||||
# Output the full image tag for deploy step
|
||||
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Also update :latest for convenience
|
||||
echo "Copying to :latest tag"
|
||||
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/auth-server:latest"
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-auth]
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
environment: production
|
||||
env:
|
||||
AUTH_IMAGE: ${{ needs.build-auth.outputs.image_tag }}
|
||||
DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }}
|
||||
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||
GH_OAUTH_CLIENT_ID: ${{ secrets.GH_OAUTH_CLIENT_ID }}
|
||||
GH_OAUTH_CLIENT_SECRET: ${{ secrets.GH_OAUTH_CLIENT_SECRET }}
|
||||
APPLE_SIGNIN_CLIENT_ID: ${{ secrets.APPLE_SIGNIN_CLIENT_ID }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_SIGNIN_KEY_ID: ${{ secrets.APPLE_SIGNIN_KEY_ID }}
|
||||
APPLE_SIGNIN_PRIVATE_KEY: ${{ secrets.APPLE_SIGNIN_PRIVATE_KEY }}
|
||||
TWITCH_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }}
|
||||
TWITCH_CLIENT_SECRET: ${{ secrets.TWITCH_CLIENT_SECRET }}
|
||||
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
|
||||
FASTMAIL_API_TOKEN: ${{ secrets.FASTMAIL_API_TOKEN }}
|
||||
FASTMAIL_FROM_EMAIL: ${{ secrets.FASTMAIL_FROM_EMAIL }}
|
||||
FASTMAIL_FROM_NAME: ${{ secrets.FASTMAIL_FROM_NAME }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy auth service to production
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.DO_DROPLET_IP }}
|
||||
username: deploy
|
||||
key: ${{ secrets.DO_SSH_KEY }}
|
||||
script_stop: true
|
||||
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,GH_OAUTH_CLIENT_ID,GH_OAUTH_CLIENT_SECRET,APPLE_SIGNIN_CLIENT_ID,APPLE_TEAM_ID,APPLE_SIGNIN_KEY_ID,APPLE_SIGNIN_PRIVATE_KEY,TWITCH_CLIENT_ID,TWITCH_CLIENT_SECRET,JWT_PRIVATE_KEY,FASTMAIL_API_TOKEN,FASTMAIL_FROM_EMAIL,FASTMAIL_FROM_NAME
|
||||
script: |
|
||||
set -x
|
||||
cd /opt/eagle0
|
||||
|
||||
# Export env vars for docker compose (appleboy/ssh-action sets them but doesn't export)
|
||||
export AUTH_IMAGE="${AUTH_IMAGE}"
|
||||
export DISCORD_CLIENT_ID="${DISCORD_CLIENT_ID}"
|
||||
export DISCORD_CLIENT_SECRET="${DISCORD_CLIENT_SECRET}"
|
||||
export GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID}"
|
||||
export GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET}"
|
||||
export GH_OAUTH_CLIENT_ID="${GH_OAUTH_CLIENT_ID}"
|
||||
export GH_OAUTH_CLIENT_SECRET="${GH_OAUTH_CLIENT_SECRET}"
|
||||
export APPLE_SIGNIN_CLIENT_ID="${APPLE_SIGNIN_CLIENT_ID}"
|
||||
export APPLE_TEAM_ID="${APPLE_TEAM_ID}"
|
||||
export APPLE_SIGNIN_KEY_ID="${APPLE_SIGNIN_KEY_ID}"
|
||||
export APPLE_SIGNIN_PRIVATE_KEY="${APPLE_SIGNIN_PRIVATE_KEY}"
|
||||
export TWITCH_CLIENT_ID="${TWITCH_CLIENT_ID}"
|
||||
export TWITCH_CLIENT_SECRET="${TWITCH_CLIENT_SECRET}"
|
||||
export JWT_PRIVATE_KEY="${JWT_PRIVATE_KEY}"
|
||||
export FASTMAIL_API_TOKEN="${FASTMAIL_API_TOKEN}"
|
||||
export FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
|
||||
export FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
|
||||
|
||||
# Login to registry
|
||||
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
|
||||
|
||||
echo "Deploying auth service: $AUTH_IMAGE"
|
||||
|
||||
# Pull the image directly (docker is already logged in)
|
||||
echo "Pulling Auth image..."
|
||||
docker pull "${AUTH_IMAGE}" || { echo "ERROR: Failed to pull auth image"; exit 1; }
|
||||
|
||||
# Tag as :latest locally so any fallback uses correct image
|
||||
docker tag "${AUTH_IMAGE}" registry.digitalocean.com/eagle0/auth-server:latest
|
||||
|
||||
# Debug: check environment and .env file
|
||||
echo "DEBUG: AUTH_IMAGE=$AUTH_IMAGE"
|
||||
env | grep AUTH || echo "AUTH_IMAGE not in env output"
|
||||
if [ -f .env ]; then
|
||||
echo "DEBUG: .env file contents related to AUTH:"
|
||||
grep AUTH .env || echo "No AUTH in .env"
|
||||
fi
|
||||
|
||||
# Recreate auth container - pass AUTH_IMAGE explicitly on command line
|
||||
AUTH_IMAGE="${AUTH_IMAGE}" docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate auth
|
||||
|
||||
# Wait for health check
|
||||
sleep 5
|
||||
|
||||
# Verify container is using the correct image
|
||||
# Note: docker-compose may use :latest tag (which we tagged to the correct image)
|
||||
echo "=== Verifying auth container image ==="
|
||||
RUNNING_IMAGE=$(docker inspect auth-server --format '{{.Config.Image}}')
|
||||
RUNNING_DIGEST=$(docker inspect auth-server --format '{{.Image}}')
|
||||
EXPECTED_DIGEST=$(docker inspect "${AUTH_IMAGE}" --format '{{.Id}}')
|
||||
echo "Expected image: ${AUTH_IMAGE}"
|
||||
echo "Running image: ${RUNNING_IMAGE}"
|
||||
echo "Expected digest: ${EXPECTED_DIGEST}"
|
||||
echo "Running digest: ${RUNNING_DIGEST}"
|
||||
|
||||
if [ "$RUNNING_DIGEST" != "$EXPECTED_DIGEST" ]; then
|
||||
echo "ERROR: Container is running wrong image!"
|
||||
exit 1
|
||||
fi
|
||||
echo "Image digests match - correct image is running"
|
||||
|
||||
# Show container status
|
||||
docker compose -f docker-compose.prod.yml ps auth
|
||||
|
||||
# Cleanup old images
|
||||
docker image prune -f
|
||||
@@ -1,38 +0,0 @@
|
||||
name: Bazel Cache Cleanup
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run weekly on Sunday at 00:00 UTC
|
||||
- cron: '0 0 * * 0'
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: [self-hosted, bazel]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Show disk usage before cleanup
|
||||
run: |
|
||||
echo "=== Disk usage before cleanup ==="
|
||||
BAZEL_USER_ROOT="/private/var/tmp/_bazel_$(whoami)"
|
||||
echo "Bazel user root: $BAZEL_USER_ROOT"
|
||||
du -sh "$BAZEL_USER_ROOT" 2>/dev/null || echo "Not found"
|
||||
df -h .
|
||||
|
||||
- name: Run bazel clean
|
||||
run: |
|
||||
echo "=== Running bazel clean ==="
|
||||
bazel clean
|
||||
echo "Clean complete"
|
||||
|
||||
- name: Show disk usage after cleanup
|
||||
run: |
|
||||
echo "=== Disk usage after cleanup ==="
|
||||
BAZEL_USER_ROOT="/private/var/tmp/_bazel_$(whoami)"
|
||||
du -sh "$BAZEL_USER_ROOT" 2>/dev/null || echo "Not found"
|
||||
df -h .
|
||||
@@ -8,7 +8,6 @@ on:
|
||||
- 'WORKSPACE'
|
||||
- 'MODULE.bazel'
|
||||
- 'BUILD.bazel'
|
||||
- '.bazelrc'
|
||||
- '.github/workflows/bazel_test.yml'
|
||||
- '!src/main/csharp/**'
|
||||
- '!src/test/csharp/**'
|
||||
@@ -18,100 +17,27 @@ on:
|
||||
- 'WORKSPACE'
|
||||
- 'MODULE.bazel'
|
||||
- 'BUILD.bazel'
|
||||
- '.bazelrc'
|
||||
- '.github/workflows/bazel_test.yml'
|
||||
- '!src/main/csharp/**'
|
||||
- '!src/test/csharp/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: [self-hosted, bazel]
|
||||
steps:
|
||||
- name: Clean workspace
|
||||
run: |
|
||||
git sparse-checkout disable 2>/dev/null || true
|
||||
git config --local core.sparseCheckout false 2>/dev/null || true
|
||||
git config --local --unset extensions.worktreeConfig 2>/dev/null || true
|
||||
rm -f .git/info/sparse-checkout .git/config.worktree 2>/dev/null || true
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
- name: Check BUILD.bazel dependencies
|
||||
run: ./scripts/check_build_deps.sh --strict
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
- name: Check JavaScript syntax
|
||||
run: node --check src/main/go/net/eagle0/admin_server/static/map_editor.js
|
||||
|
||||
test:
|
||||
runs-on: [self-hosted, bazel]
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Clean workspace
|
||||
run: |
|
||||
git sparse-checkout disable 2>/dev/null || true
|
||||
git config --local core.sparseCheckout false 2>/dev/null || true
|
||||
git config --local --unset extensions.worktreeConfig 2>/dev/null || true
|
||||
rm -f .git/info/sparse-checkout .git/config.worktree 2>/dev/null || true
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
- name: Run tests
|
||||
run: bazel test --build_event_json_file=test.json //src/test/... //src/main/go/...
|
||||
- name: Collect failed test logs
|
||||
if: always()
|
||||
run: |
|
||||
# Remove any existing failed_test_logs directory and create fresh
|
||||
rm -rf failed_test_logs
|
||||
mkdir -p failed_test_logs
|
||||
# Extract failed test targets from test.json and copy their logs
|
||||
# The test.json is in JSONL format - one JSON object per line
|
||||
# We look for lines with testResult that have a status other than PASSED
|
||||
if [ -f test.json ]; then
|
||||
grep '"testResult"' test.json | \
|
||||
grep '"status"' | \
|
||||
grep -v '"status":"PASSED"' | \
|
||||
grep -o '"label":"[^"]*"' | \
|
||||
cut -d'"' -f4 | \
|
||||
sort -u | \
|
||||
while read target; do
|
||||
# Convert target like //src/test/cpp/...:test_name to path
|
||||
log_path=$(echo "$target" | sed 's|^//||' | sed 's|:|/|')
|
||||
if [ -f "bazel-testlogs/$log_path/test.log" ]; then
|
||||
log_name=$(echo "$log_path" | tr '/' '_')
|
||||
if cp "bazel-testlogs/$log_path/test.log" "failed_test_logs/${log_name}.log"; then
|
||||
echo "Collected log for failed test: $target"
|
||||
else
|
||||
echo "Error: Failed to copy log for $target"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
# List what we collected
|
||||
echo "Collected logs:"
|
||||
ls -lh failed_test_logs/ 2>/dev/null || echo "No logs collected"
|
||||
- name: Archive test results
|
||||
if: always()
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test.json
|
||||
path: test.json
|
||||
retention-days: 3
|
||||
- name: Archive failed test logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: failed-test-logs
|
||||
path: failed_test_logs/
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
name: Blob Cleanup
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run daily at 04:00 UTC
|
||||
- cron: '0 4 * * *'
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: [self-hosted, bazel]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
clean: false
|
||||
|
||||
- name: Clean up unreferenced blobs
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: |
|
||||
bazel run //src/main/go/net/eagle0/build/blob_cleanup:blob_cleanup -- --min-age=1h
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Build Protos
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "src/main/protobuf/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
- name: Run tests
|
||||
run: ./scripts/build_protos.sh
|
||||
@@ -1,139 +0,0 @@
|
||||
name: Build Linux Sysroot
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Sysroot version (e.g., v2, v3)'
|
||||
required: true
|
||||
default: 'v2'
|
||||
type: string
|
||||
architecture:
|
||||
description: 'Target architecture'
|
||||
required: true
|
||||
default: 'amd64'
|
||||
type: choice
|
||||
options:
|
||||
- amd64
|
||||
- arm64
|
||||
- both
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-sysroot-amd64:
|
||||
if: ${{ inputs.architecture == 'amd64' || inputs.architecture == 'both' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build sysroot
|
||||
run: ./tools/sysroot/build_sysroot.sh
|
||||
|
||||
- name: Upload sysroot artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ubuntu-noble-sysroot-amd64
|
||||
path: tools/sysroot/output/
|
||||
retention-days: 1
|
||||
|
||||
- name: Install AWS CLI
|
||||
run: |
|
||||
if ! command -v aws &> /dev/null; then
|
||||
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
|
||||
unzip -q awscliv2.zip
|
||||
sudo ./aws/install
|
||||
fi
|
||||
|
||||
- name: Upload to DigitalOcean Spaces
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: |
|
||||
# Upload sysroot tarball to DO Spaces
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.tar.xz \
|
||||
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
--acl public-read
|
||||
|
||||
# Upload sha256 file
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256 \
|
||||
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.sha256 \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
--acl public-read
|
||||
|
||||
echo ""
|
||||
echo "=== AMD64 Sysroot uploaded ==="
|
||||
echo "URL: https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz"
|
||||
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)"
|
||||
echo ""
|
||||
echo "Update MODULE.bazel with:"
|
||||
echo "sysroot("
|
||||
echo " name = \"linux_sysroot\","
|
||||
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)\","
|
||||
echo " urls = [\"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
|
||||
echo ")"
|
||||
|
||||
build-sysroot-arm64:
|
||||
if: ${{ inputs.architecture == 'arm64' || inputs.architecture == 'both' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU for ARM64 emulation
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build ARM64 sysroot
|
||||
run: ./tools/sysroot/build_sysroot_arm64.sh
|
||||
|
||||
- name: Upload sysroot artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ubuntu-noble-sysroot-arm64
|
||||
path: tools/sysroot/output/
|
||||
retention-days: 1
|
||||
|
||||
- name: Install AWS CLI
|
||||
run: |
|
||||
if ! command -v aws &> /dev/null; then
|
||||
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
|
||||
unzip -q awscliv2.zip
|
||||
sudo ./aws/install
|
||||
fi
|
||||
|
||||
- name: Upload to DigitalOcean Spaces
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: |
|
||||
# Upload sysroot tarball to DO Spaces
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_arm64_sysroot.tar.xz \
|
||||
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
--acl public-read
|
||||
|
||||
# Upload sha256 file
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256 \
|
||||
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.sha256 \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
--acl public-read
|
||||
|
||||
echo ""
|
||||
echo "=== ARM64 Sysroot uploaded ==="
|
||||
echo "URL: https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz"
|
||||
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256)"
|
||||
echo ""
|
||||
echo "Update MODULE.bazel with:"
|
||||
echo "sysroot("
|
||||
echo " name = \"linux_sysroot_arm64\","
|
||||
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256)\","
|
||||
echo " urls = [\"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz\"],"
|
||||
echo ")"
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Client Presigner
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- ".github/workflows/client_presigner.yml"
|
||||
- "src/main/go/net/eagle0/client_download/**"
|
||||
- "src/main/go/net/eagle0/util/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/client_presigner.yml"
|
||||
- "src/main/go/net/eagle0/client_download/**"
|
||||
- "src/main/go/net/eagle0/util/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
client-presigner:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
clean: false
|
||||
- name: Build Client Presigner
|
||||
run: bazel build --platforms=@io_bazel_rules_go//go/toolchain:linux_amd64 //src/main/go/net/eagle0/client_download
|
||||
- name: Archive presigner binary
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: client_download
|
||||
path: bazel-bin/src/main/go/net/eagle0/client_download/client_download_/client_download
|
||||
@@ -1,489 +0,0 @@
|
||||
name: Docker Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
# Note: C++ changes trigger shardok_arm64_build.yml instead
|
||||
# Note: Auth changes trigger auth_build.yml instead
|
||||
# Note: Windows installer changes trigger installer_build.yml instead
|
||||
- 'src/main/go/**'
|
||||
- '!src/main/go/net/eagle0/authservice/**'
|
||||
- '!src/main/go/net/eagle0/authcli/**'
|
||||
- '!src/main/go/net/eagle0/clients/**'
|
||||
- 'src/main/scala/**'
|
||||
- 'src/main/protobuf/**'
|
||||
- '!src/main/protobuf/net/eagle0/eagle/api/auth.proto'
|
||||
- '!src/main/protobuf/net/eagle0/eagle/api/admin/**'
|
||||
- '!src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto'
|
||||
- 'src/main/resources/**'
|
||||
- 'ci/BUILD.bazel'
|
||||
- 'MODULE.bazel'
|
||||
- '.bazelrc'
|
||||
- 'docker-compose.prod.yml'
|
||||
- 'nginx/**'
|
||||
- '.github/workflows/docker_build.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push_images:
|
||||
description: 'Push images to container registry'
|
||||
required: true
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
# Only allow one deployment at a time; queue new ones (never cancel a running deploy).
|
||||
# The build-all job checks if it's still the latest commit on main and skips if not,
|
||||
# so intermediate commits don't waste time building when a newer one is already queued.
|
||||
concurrency:
|
||||
group: docker-build-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # Required to delete artifacts after deploy
|
||||
|
||||
jobs:
|
||||
# Single consolidated build job - builds all images with one bazel invocation
|
||||
# This uses 1 runner slot instead of 4, and Bazel parallelizes internally
|
||||
# NOTE: Must run on a runner with the bazel label to ensure consistent Bazel cache
|
||||
build-all:
|
||||
runs-on: [self-hosted, bazel]
|
||||
outputs:
|
||||
eagle_image_tag: ${{ steps.push-images.outputs.eagle_image_tag }}
|
||||
admin_image_tag: ${{ steps.push-images.outputs.admin_image_tag }}
|
||||
jfr_sidecar_image_tag: ${{ steps.push-images.outputs.jfr_sidecar_image_tag }}
|
||||
steps:
|
||||
- name: Skip if superseded
|
||||
id: check-latest
|
||||
run: |
|
||||
# Check if there's a newer run of this workflow waiting in the queue.
|
||||
# If so, skip this build — the queued run will deploy a newer commit.
|
||||
QUEUED=$(curl -s -H "Authorization: Bearer $GH_TOKEN" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/actions/workflows/docker_build.yml/runs?status=queued" \
|
||||
| python3 -c "import sys,json; runs=json.load(sys.stdin).get('workflow_runs',[]); print(len([r for r in runs if r['run_number'] > ${{ github.run_number }}]))")
|
||||
if [ "$QUEUED" -gt 0 ]; then
|
||||
echo "::notice::Skipping build — $QUEUED newer run(s) queued for this workflow"
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Checkout repository
|
||||
if: steps.check-latest.outputs.skip != 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Fetch LFS files needed for admin server
|
||||
if: steps.check-latest.outputs.skip != 'true'
|
||||
run: ./ci/github_actions/fetch_lfs.sh --include="src/main/go/net/eagle0/admin_server/static/tiles/*"
|
||||
|
||||
- name: Build all Docker images
|
||||
if: steps.check-latest.outputs.skip != 'true'
|
||||
id: build-all
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
# Build ALL images in a single bazel command - Bazel parallelizes internally
|
||||
# Note: Shardok is built separately for ARM64 and deployed to Hetzner
|
||||
# --stamp is needed for admin_server x_defs (git commit, build time)
|
||||
echo "=== Building Docker images ==="
|
||||
bazel build \
|
||||
--stamp \
|
||||
--platforms=//:linux_x86_64 \
|
||||
--extra_toolchains=@llvm_toolchain_linux//:all \
|
||||
//ci:eagle_server_image \
|
||||
//ci:admin_server_image \
|
||||
//ci:jfr_sidecar_image \
|
||||
//ci:warmup_tar
|
||||
|
||||
# Extract warmup binary from tar for deployment
|
||||
mkdir -p scripts/bin
|
||||
tar -xf bazel-bin/ci/warmup_tar.tar -C scripts/bin --strip-components=1
|
||||
|
||||
# Save all image paths before any other bazel command changes bazel-bin symlink
|
||||
EAGLE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
|
||||
ADMIN_PATH=$(readlink -f bazel-bin/ci/admin_server_image)
|
||||
JFR_PATH=$(readlink -f bazel-bin/ci/jfr_sidecar_image)
|
||||
|
||||
echo "eagle_path=$EAGLE_PATH" >> $GITHUB_OUTPUT
|
||||
echo "admin_path=$ADMIN_PATH" >> $GITHUB_OUTPUT
|
||||
echo "jfr_path=$JFR_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "=== Image paths ==="
|
||||
echo "Eagle: $EAGLE_PATH"
|
||||
echo "Admin: $ADMIN_PATH"
|
||||
echo "JFR Sidecar: $JFR_PATH"
|
||||
|
||||
- name: Upload warmup binary
|
||||
if: steps.check-latest.outputs.skip != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: warmup-binary
|
||||
path: scripts/bin/warmup
|
||||
retention-days: 1
|
||||
|
||||
- name: Login to DigitalOcean Container Registry
|
||||
if: steps.check-latest.outputs.skip != 'true' && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true'))
|
||||
env:
|
||||
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
run: |
|
||||
mkdir -p ~/.docker
|
||||
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
|
||||
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
mkdir -p .docker
|
||||
cp ~/.docker/config.json .docker/
|
||||
|
||||
- name: Push all images to DO registry
|
||||
id: push-images
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DOCKER_CONFIG: ${{ github.workspace }}/.docker
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
GIT_SHA=$(git rev-parse --short=8 HEAD)
|
||||
|
||||
# Get crane (cached across runs to avoid rebuilding bazel target)
|
||||
CRANE_VERSION="v0.20.2"
|
||||
CRANE_DIR="${HOME}/.local/bin"
|
||||
CRANE="${CRANE_DIR}/crane"
|
||||
if [ ! -x "$CRANE" ] || ! "$CRANE" version 2>/dev/null | grep -q "0.20.2"; then
|
||||
echo "Installing crane ${CRANE_VERSION}..."
|
||||
mkdir -p "$CRANE_DIR"
|
||||
curl -sL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Darwin_arm64.tar.gz" | tar xzf - -C "$CRANE_DIR" crane
|
||||
chmod +x "$CRANE"
|
||||
else
|
||||
echo "Using cached crane at $CRANE"
|
||||
fi
|
||||
|
||||
# Set up image paths and tags
|
||||
EAGLE_IMAGE="${{ steps.build-all.outputs.eagle_path }}"
|
||||
EAGLE_TAG="registry.digitalocean.com/eagle0/eagle-server:${GIT_SHA}"
|
||||
ADMIN_IMAGE="${{ steps.build-all.outputs.admin_path }}"
|
||||
ADMIN_TAG="registry.digitalocean.com/eagle0/admin-server:${GIT_SHA}"
|
||||
JFR_IMAGE="${{ steps.build-all.outputs.jfr_path }}"
|
||||
JFR_TAG="registry.digitalocean.com/eagle0/jfr-sidecar:${GIT_SHA}"
|
||||
|
||||
# Push all three images in parallel
|
||||
echo "Pushing Eagle: $EAGLE_TAG"
|
||||
$CRANE push "$EAGLE_IMAGE" "$EAGLE_TAG" &
|
||||
PID_EAGLE=$!
|
||||
|
||||
echo "Pushing Admin: $ADMIN_TAG"
|
||||
$CRANE push "$ADMIN_IMAGE" "$ADMIN_TAG" &
|
||||
PID_ADMIN=$!
|
||||
|
||||
echo "Pushing JFR Sidecar: $JFR_TAG"
|
||||
$CRANE push "$JFR_IMAGE" "$JFR_TAG" &
|
||||
PID_JFR=$!
|
||||
|
||||
# Wait for all pushes to complete
|
||||
PUSH_FAILED=0
|
||||
wait $PID_EAGLE || PUSH_FAILED=1
|
||||
wait $PID_ADMIN || PUSH_FAILED=1
|
||||
wait $PID_JFR || PUSH_FAILED=1
|
||||
if [ $PUSH_FAILED -ne 0 ]; then
|
||||
echo "ERROR: One or more image pushes failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "All images pushed, tagging :latest..."
|
||||
|
||||
# Tag :latest in parallel
|
||||
$CRANE copy "$EAGLE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest" &
|
||||
$CRANE copy "$ADMIN_TAG" "registry.digitalocean.com/eagle0/admin-server:latest" &
|
||||
$CRANE copy "$JFR_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest" &
|
||||
wait
|
||||
|
||||
echo "eagle_image_tag=$EAGLE_TAG" >> $GITHUB_OUTPUT
|
||||
echo "admin_image_tag=$ADMIN_TAG" >> $GITHUB_OUTPUT
|
||||
echo "jfr_sidecar_image_tag=$JFR_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "=== All images pushed successfully ==="
|
||||
|
||||
deploy:
|
||||
runs-on: [self-hosted, bazel]
|
||||
needs: [build-all]
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
environment: production
|
||||
env:
|
||||
EAGLE_IMAGE: ${{ needs.build-all.outputs.eagle_image_tag }}
|
||||
ADMIN_IMAGE: ${{ needs.build-all.outputs.admin_image_tag }}
|
||||
JFR_SIDECAR_IMAGE: ${{ needs.build-all.outputs.jfr_sidecar_image_tag }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GPT_MODEL_NAME: ${{ secrets.GPT_MODEL_NAME }}
|
||||
EAGLE_ENABLE_S3: ${{ secrets.EAGLE_ENABLE_S3 }}
|
||||
DO_SPACES_ACCESS_KEY: ${{ secrets.DO_SPACES_ACCESS_KEY }}
|
||||
DO_SPACES_SECRET_KEY: ${{ secrets.DO_SPACES_SECRET_KEY }}
|
||||
# Admin server uses different credentials (for eagle0-assets bucket)
|
||||
ADMIN_S3_ACCESS_KEY: ${{ secrets.ACCESS_KEY_ID }}
|
||||
ADMIN_S3_SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
|
||||
DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }}
|
||||
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||
GH_OAUTH_CLIENT_ID: ${{ secrets.GH_OAUTH_CLIENT_ID }}
|
||||
GH_OAUTH_CLIENT_SECRET: ${{ secrets.GH_OAUTH_CLIENT_SECRET }}
|
||||
APPLE_SIGNIN_CLIENT_ID: ${{ secrets.APPLE_SIGNIN_CLIENT_ID }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
APPLE_SIGNIN_KEY_ID: ${{ secrets.APPLE_SIGNIN_KEY_ID }}
|
||||
APPLE_SIGNIN_PRIVATE_KEY: ${{ secrets.APPLE_SIGNIN_PRIVATE_KEY }}
|
||||
TWITCH_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }}
|
||||
TWITCH_CLIENT_SECRET: ${{ secrets.TWITCH_CLIENT_SECRET }}
|
||||
SHARDOK_ADDRESS: ${{ secrets.SHARDOK_ADDRESS }}
|
||||
SHARDOK_AUTH_TOKEN: ${{ secrets.SHARDOK_AUTH_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
FASTMAIL_API_TOKEN: ${{ secrets.FASTMAIL_API_TOKEN }}
|
||||
FASTMAIL_FROM_EMAIL: ${{ secrets.FASTMAIL_FROM_EMAIL }}
|
||||
FASTMAIL_FROM_NAME: ${{ secrets.FASTMAIL_FROM_NAME }}
|
||||
DO_DROPLET_IP: ${{ secrets.DO_DROPLET_IP }}
|
||||
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
NOTIFY_SECRET: ${{ secrets.EAGLE_NOTIFY_SECRET }}
|
||||
GITHUB_TOKEN_FOR_ADMIN: ${{ secrets.ADMIN_GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DO_SSH_KEY }}" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
ssh-keyscan -H "$DO_DROPLET_IP" >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
- name: Download warmup binary
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: warmup-binary
|
||||
path: scripts/bin/
|
||||
|
||||
- name: Copy config files to droplet
|
||||
run: |
|
||||
# Create directory structure on remote
|
||||
ssh -i ~/.ssh/deploy_key deploy@"$DO_DROPLET_IP" bash -s << 'SETUP_DIRS'
|
||||
set -e
|
||||
mkdir -p /opt/eagle0/scripts/bin /opt/eagle0/nginx
|
||||
rm -f /opt/eagle0/scripts/bin/warmup
|
||||
SETUP_DIRS
|
||||
|
||||
# Copy files
|
||||
scp -i ~/.ssh/deploy_key docker-compose.prod.yml deploy@"$DO_DROPLET_IP":/opt/eagle0/
|
||||
scp -i ~/.ssh/deploy_key nginx/nginx.conf deploy@"$DO_DROPLET_IP":/opt/eagle0/nginx/
|
||||
scp -i ~/.ssh/deploy_key scripts/deploy-blue-green.sh scripts/warmup-eagle.sh scripts/eagle-exec.sh scripts/eagle-logs.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/
|
||||
scp -i ~/.ssh/deploy_key scripts/bin/warmup deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/bin/
|
||||
|
||||
- name: Deploy to production droplet
|
||||
run: |
|
||||
ssh -i ~/.ssh/deploy_key deploy@"$DO_DROPLET_IP" bash -s << DEPLOY_SCRIPT
|
||||
set -ex
|
||||
cd /opt/eagle0
|
||||
|
||||
# =================================================================
|
||||
# CRITICAL: Validate environment variables before proceeding
|
||||
# This catches GitHub Actions secret store hiccups early
|
||||
# =================================================================
|
||||
validate_env() {
|
||||
local var_name="\$1"
|
||||
local var_value="\$2"
|
||||
local default_value="\${3:-}"
|
||||
|
||||
if [ -z "\${var_value}" ]; then
|
||||
echo "ERROR: \${var_name} is empty. GitHub Actions secrets may have failed to load."
|
||||
echo "Please retry the workflow."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if we got a default value instead of the real secret
|
||||
if [ -n "\${default_value}" ] && [ "\${var_value}" = "\${default_value}" ]; then
|
||||
echo "ERROR: \${var_name} has default value '\${default_value}' instead of the actual secret."
|
||||
echo "This indicates GitHub Actions secrets failed to load. Please retry the workflow."
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
echo "Validating critical environment variables..."
|
||||
|
||||
# These are the raw values from GitHub Actions (before export)
|
||||
# We check them before exporting to catch issues early
|
||||
VALIDATION_FAILED=0
|
||||
|
||||
validate_env "SHARDOK_ADDRESS" "${SHARDOK_ADDRESS}" "" || VALIDATION_FAILED=1
|
||||
validate_env "EAGLE_IMAGE" "${EAGLE_IMAGE}" "" || VALIDATION_FAILED=1
|
||||
validate_env "JWT_PRIVATE_KEY" "${JWT_PRIVATE_KEY}" "" || VALIDATION_FAILED=1
|
||||
validate_env "DO_REGISTRY_TOKEN" "${DO_REGISTRY_TOKEN}" "" || VALIDATION_FAILED=1
|
||||
|
||||
if [ "\${VALIDATION_FAILED}" -eq 1 ]; then
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo "DEPLOYMENT ABORTED: Missing critical secrets"
|
||||
echo "This is likely a transient GitHub Actions issue."
|
||||
echo "Please retry the workflow."
|
||||
echo "========================================="
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All critical environment variables validated successfully."
|
||||
|
||||
# =================================================================
|
||||
# Export environment variables for docker compose
|
||||
# These are passed via heredoc and exported so child processes (docker compose) can access them
|
||||
export EAGLE_IMAGE="${EAGLE_IMAGE}"
|
||||
export ADMIN_IMAGE="${ADMIN_IMAGE}"
|
||||
export JFR_SIDECAR_IMAGE="${JFR_SIDECAR_IMAGE}"
|
||||
export OPENAI_API_KEY="${OPENAI_API_KEY}"
|
||||
export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY}"
|
||||
export GEMINI_API_KEY="${GEMINI_API_KEY}"
|
||||
export GPT_MODEL_NAME="${GPT_MODEL_NAME:-gpt-4o}"
|
||||
export EAGLE_ENABLE_S3="${EAGLE_ENABLE_S3:-false}"
|
||||
export DO_SPACES_ACCESS_KEY="${DO_SPACES_ACCESS_KEY}"
|
||||
export DO_SPACES_SECRET_KEY="${DO_SPACES_SECRET_KEY}"
|
||||
export ADMIN_S3_ACCESS_KEY="${ADMIN_S3_ACCESS_KEY}"
|
||||
export ADMIN_S3_SECRET_KEY="${ADMIN_S3_SECRET_KEY}"
|
||||
export JWT_PRIVATE_KEY="${JWT_PRIVATE_KEY}"
|
||||
export DISCORD_CLIENT_ID="${DISCORD_CLIENT_ID}"
|
||||
export DISCORD_CLIENT_SECRET="${DISCORD_CLIENT_SECRET}"
|
||||
export GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID}"
|
||||
export GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET}"
|
||||
export GH_OAUTH_CLIENT_ID="${GH_OAUTH_CLIENT_ID}"
|
||||
export GH_OAUTH_CLIENT_SECRET="${GH_OAUTH_CLIENT_SECRET}"
|
||||
export APPLE_SIGNIN_CLIENT_ID="${APPLE_SIGNIN_CLIENT_ID}"
|
||||
export APPLE_TEAM_ID="${APPLE_TEAM_ID}"
|
||||
export APPLE_SIGNIN_KEY_ID="${APPLE_SIGNIN_KEY_ID}"
|
||||
export APPLE_SIGNIN_PRIVATE_KEY="${APPLE_SIGNIN_PRIVATE_KEY}"
|
||||
export TWITCH_CLIENT_ID="${TWITCH_CLIENT_ID}"
|
||||
export TWITCH_CLIENT_SECRET="${TWITCH_CLIENT_SECRET}"
|
||||
export SHARDOK_ADDRESS="${SHARDOK_ADDRESS}"
|
||||
export SHARDOK_AUTH_TOKEN="${SHARDOK_AUTH_TOKEN}"
|
||||
export SENTRY_DSN="${SENTRY_DSN}"
|
||||
export FASTMAIL_API_TOKEN="${FASTMAIL_API_TOKEN}"
|
||||
export FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
|
||||
export FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
|
||||
export DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
|
||||
export NOTIFY_SECRET="${NOTIFY_SECRET}"
|
||||
export GITHUB_TOKEN="${GITHUB_TOKEN_FOR_ADMIN}"
|
||||
|
||||
# Check Docker has IPv6 support
|
||||
if ! cat /etc/docker/daemon.json 2>/dev/null | grep -q '"ip6tables"'; then
|
||||
echo "WARNING: Docker IPv6 not configured. Eagle may not reach Hetzner Shardok."
|
||||
fi
|
||||
|
||||
# Login to registry
|
||||
echo "\$DO_REGISTRY_TOKEN" | docker login registry.digitalocean.com -u "\$DO_REGISTRY_TOKEN" --password-stdin
|
||||
|
||||
echo "Using images: \$EAGLE_IMAGE, \$ADMIN_IMAGE, \$JFR_SIDECAR_IMAGE"
|
||||
|
||||
# Install crane for pulling OCI images (cached across deploys)
|
||||
CRANE_VERSION="v0.20.2"
|
||||
if [ ! -x crane ] || ! ./crane version 2>/dev/null | grep -q "0.20.2"; then
|
||||
echo "Installing crane \${CRANE_VERSION}..."
|
||||
rm -f crane
|
||||
curl -sL "https://github.com/google/go-containerregistry/releases/download/\${CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz" | tar xzf - crane
|
||||
chmod +x crane
|
||||
else
|
||||
echo "Using cached crane"
|
||||
fi
|
||||
|
||||
# Pull all images in parallel, then load sequentially
|
||||
echo "Pulling all images in parallel..."
|
||||
./crane pull "\${EAGLE_IMAGE}" eagle.tar &
|
||||
./crane pull "\${ADMIN_IMAGE}" admin.tar &
|
||||
./crane pull "\${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar &
|
||||
docker pull nginx:alpine &
|
||||
docker pull certbot/certbot &
|
||||
wait
|
||||
|
||||
echo "Loading images..."
|
||||
docker load -i eagle.tar && rm eagle.tar
|
||||
docker load -i admin.tar && rm admin.tar
|
||||
# Tag as :latest locally so docker-compose fallback uses correct image
|
||||
docker tag "\${ADMIN_IMAGE}" registry.digitalocean.com/eagle0/admin-server:latest
|
||||
docker load -i jfr-sidecar.tar && rm jfr-sidecar.tar
|
||||
|
||||
echo "All images pulled and loaded successfully"
|
||||
|
||||
# =================================================================
|
||||
# Verify Shardok connectivity before proceeding with deployment
|
||||
# This catches network/firewall issues early
|
||||
# =================================================================
|
||||
echo "Verifying Shardok connectivity..."
|
||||
SHARDOK_HOST=\$(echo "\${SHARDOK_ADDRESS}" | cut -d: -f1)
|
||||
SHARDOK_PORT=\$(echo "\${SHARDOK_ADDRESS}" | cut -d: -f2)
|
||||
|
||||
# Try to connect to Shardok (timeout after 10 seconds)
|
||||
if nc -z -w 10 "\${SHARDOK_HOST}" "\${SHARDOK_PORT}" 2>/dev/null; then
|
||||
echo "Shardok connectivity verified: \${SHARDOK_ADDRESS} is reachable"
|
||||
else
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo "ERROR: Cannot reach Shardok at \${SHARDOK_ADDRESS}"
|
||||
echo "This may indicate:"
|
||||
echo " - Shardok server is not running on Hetzner"
|
||||
echo " - Network/firewall issues between DigitalOcean and Hetzner"
|
||||
echo " - Incorrect SHARDOK_ADDRESS configuration"
|
||||
echo ""
|
||||
echo "DEPLOYMENT ABORTED: Shardok must be reachable for battles to work."
|
||||
echo "========================================="
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stop local shardok container if running (now runs on Hetzner)
|
||||
docker stop shardok-server 2>/dev/null || true
|
||||
docker rm shardok-server 2>/dev/null || true
|
||||
|
||||
# Deploy Eagle with blue-green (handles eagle, nginx, admin, jfr-sidecar)
|
||||
# Note: Shardok runs on Hetzner, deployed separately via shardok_arm64_build.yml
|
||||
# Note: Auth is deployed separately via auth_build.yml - do NOT touch auth here
|
||||
chmod +x /opt/eagle0/scripts/*.sh
|
||||
[ -f "/opt/eagle0/scripts/bin/warmup" ] && chmod +x /opt/eagle0/scripts/bin/warmup
|
||||
GIT_SHA=\$(echo "\${EAGLE_IMAGE}" | sed 's/.*://')
|
||||
/opt/eagle0/scripts/deploy-blue-green.sh "\${GIT_SHA}"
|
||||
|
||||
# Verify
|
||||
sleep 10
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
docker compose -f docker-compose.prod.yml images
|
||||
|
||||
# Verify admin container is running the correct image
|
||||
echo "=== Verifying admin container image ==="
|
||||
ADMIN_RUNNING_DIGEST=\$(docker inspect admin-server --format '{{.Image}}')
|
||||
ADMIN_EXPECTED_DIGEST=\$(docker inspect "\${ADMIN_IMAGE}" --format '{{.Id}}')
|
||||
echo "Expected image: \${ADMIN_IMAGE}"
|
||||
echo "Expected digest: \${ADMIN_EXPECTED_DIGEST}"
|
||||
echo "Running digest: \${ADMIN_RUNNING_DIGEST}"
|
||||
if [ "\${ADMIN_RUNNING_DIGEST}" != "\${ADMIN_EXPECTED_DIGEST}" ]; then
|
||||
echo "ERROR: Admin container is running wrong image!"
|
||||
echo "Container entrypoint:"
|
||||
docker inspect admin-server --format '{{.Config.Entrypoint}}'
|
||||
exit 1
|
||||
fi
|
||||
echo "Admin image verification passed"
|
||||
|
||||
# Cleanup - remove old images to prevent disk full
|
||||
docker container prune -f
|
||||
docker image prune -f
|
||||
# Remove images older than 24h (keeps recent images for rollback)
|
||||
docker image prune -a -f --filter "until=24h" || true
|
||||
DEPLOY_SCRIPT
|
||||
|
||||
cleanup:
|
||||
needs: [build-all, deploy]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Delete warmup-binary artifact
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
ARTIFACT_ID=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" \
|
||||
-q '.artifacts[] | select(.name == "warmup-binary") | .id')
|
||||
if [ -n "$ARTIFACT_ID" ]; then
|
||||
echo "Deleting warmup-binary artifact (ID: $ARTIFACT_ID)"
|
||||
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$ARTIFACT_ID" || true
|
||||
fi
|
||||
@@ -1,33 +0,0 @@
|
||||
name: Eagle Build
|
||||
|
||||
on:
|
||||
# Main pushes are covered by docker_build.yml which builds the same target
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/main/scala/**'
|
||||
- 'src/main/protobuf/net/eagle0/eagle/**'
|
||||
- 'src/main/protobuf/net/eagle0/common/**'
|
||||
- 'WORKSPACE'
|
||||
- 'MODULE.bazel'
|
||||
- 'BUILD.bazel'
|
||||
- '.bazelrc'
|
||||
- '.github/workflows/eagle_build.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: [self-hosted, bazel]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
- name: Build Eagle server
|
||||
run: bazel build //src/main/scala/net/eagle0/eagle:eagle_server
|
||||
@@ -5,20 +5,18 @@ on:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- ".github/workflows/installer_build.yml"
|
||||
- "src/main/go/net/eagle0/clients/win/installer/**"
|
||||
- "src/main/csharp/net/eagle0/clients/win/installer/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/installer_build.yml"
|
||||
- "src/main/go/net/eagle0/clients/win/installer/**"
|
||||
workflow_dispatch:
|
||||
- "src/main/csharp/net/eagle0/clients/win/installer/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write # Required to delete artifacts after deploy
|
||||
|
||||
jobs:
|
||||
build-installer:
|
||||
runs-on: [self-hosted, bazel]
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -26,47 +24,34 @@ jobs:
|
||||
lfs: false
|
||||
clean: false
|
||||
|
||||
- name: Build Go installer for Windows
|
||||
env:
|
||||
MANIFEST_PUBLIC_KEY: ${{ secrets.MANIFEST_PUBLIC_KEY }}
|
||||
run: |
|
||||
# Require manifest public key for production builds
|
||||
if [ -z "$MANIFEST_PUBLIC_KEY" ]; then
|
||||
echo "ERROR: MANIFEST_PUBLIC_KEY secret is not set"
|
||||
echo "The installer requires a public key for manifest signature verification"
|
||||
exit 1
|
||||
fi
|
||||
- name: Setup .NET 8
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
|
||||
# Build Windows installer with WebView GUI (uses CGO cross-compilation)
|
||||
# Use --action_env to pass the signing key into the genrule sandbox
|
||||
bazel build //src/main/go/net/eagle0/clients/win/installer:eagle_installer_windows_amd64_webview --stamp --action_env=MANIFEST_PUBLIC_KEY
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/EagleInstaller.csproj
|
||||
|
||||
# Copy to output directory
|
||||
rm -rf ./installer-output
|
||||
mkdir -p ./installer-output
|
||||
cp bazel-bin/src/main/go/net/eagle0/clients/win/installer/Eagle0.exe ./installer-output/Eagle0.exe
|
||||
|
||||
echo "Go installer size: $(ls -lh ./installer-output/Eagle0.exe | awk '{print $5}')"
|
||||
- name: Build installer
|
||||
run: dotnet publish src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/EagleInstaller.csproj -c Release -r win-x64 --self-contained true --output ./installer-output
|
||||
|
||||
- name: Archive installer binary
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: eagle-installer
|
||||
path: ./installer-output/
|
||||
retention-days: 1
|
||||
path: ./installer-output/EagleInstaller.exe
|
||||
|
||||
- name: Verify installer exists
|
||||
if: success()
|
||||
run: |
|
||||
echo "=== Installer output directory ==="
|
||||
ls -lh ./installer-output/
|
||||
|
||||
if [ ! -f "./installer-output/Eagle0.exe" ]; then
|
||||
echo "ERROR: Eagle0.exe not found"
|
||||
if [ ! -f "./installer-output/EagleInstaller.exe" ]; then
|
||||
echo "ERROR: EagleInstaller.exe not found at expected location"
|
||||
echo "Directory contents:"
|
||||
ls -la ./installer-output/
|
||||
exit 1
|
||||
fi
|
||||
echo "Installer found"
|
||||
echo "Installer found at correct location"
|
||||
|
||||
- name: Deploy installer
|
||||
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
@@ -74,67 +59,24 @@ jobs:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: |
|
||||
INSTALLER_PATH="$(pwd)/installer-output/Eagle0.exe"
|
||||
echo "Deploying Go installer to installer/Eagle0.exe"
|
||||
bazel run //src/main/go/net/eagle0/build/installer_build_handler:installer_build_handler -- "$INSTALLER_PATH" "installer/Eagle0.exe"
|
||||
INSTALLER_PATH="$(pwd)/installer-output/EagleInstaller.exe"
|
||||
echo "Using absolute path: $INSTALLER_PATH"
|
||||
bazel run //src/main/go/net/eagle0/build/installer_build_handler:installer_build_handler -- "$INSTALLER_PATH"
|
||||
|
||||
- name: Update manifest
|
||||
- name: Update unified manifest
|
||||
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
|
||||
run: |
|
||||
INSTALLER_SHA=$(sha256sum ./installer-output/Eagle0.exe | cut -d' ' -f1)
|
||||
# Create installer manifest content
|
||||
INSTALLER_SHA=$(sha256sum ./installer-output/EagleInstaller.exe | cut -d' ' -f1)
|
||||
echo "installer_version=$INSTALLER_SHA" > /tmp/installer_manifest.txt
|
||||
echo "installer_url=installer/Eagle0.exe" >> /tmp/installer_manifest.txt
|
||||
|
||||
echo "=== Manifest content ==="
|
||||
echo "installer_url=installer/EagleInstaller.exe" >> /tmp/installer_manifest.txt
|
||||
|
||||
echo "=== Installer manifest content ==="
|
||||
cat /tmp/installer_manifest.txt
|
||||
echo "========================"
|
||||
|
||||
# Write signing key to temp file (if available)
|
||||
SIGNING_ARGS=""
|
||||
if [ -n "$MANIFEST_SIGNING_KEY" ]; then
|
||||
echo "$MANIFEST_SIGNING_KEY" > /tmp/manifest_signing_key
|
||||
chmod 600 /tmp/manifest_signing_key
|
||||
SIGNING_ARGS="/tmp/manifest_signing_key"
|
||||
echo "Manifest signing key available"
|
||||
else
|
||||
echo "Warning: MANIFEST_SIGNING_KEY not set, manifest will be unsigned"
|
||||
fi
|
||||
|
||||
# Update the v2 manifest at installer/v2/eagle0_manifest.txt
|
||||
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- installer-v2 /tmp/installer_manifest.txt $SIGNING_ARGS
|
||||
|
||||
rm -f /tmp/manifest_signing_key
|
||||
|
||||
- name: Delete all installer artifacts
|
||||
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# Delete ALL eagle-installer artifacts to free up storage
|
||||
echo "Fetching all eagle-installer artifacts..."
|
||||
page=1
|
||||
while true; do
|
||||
response=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/actions/artifacts?per_page=100&page=$page")
|
||||
ids=$(echo "$response" | python3 -c "
|
||||
import json, sys
|
||||
data = json.load(sys.stdin)
|
||||
for a in data.get('artifacts', []):
|
||||
if a['name'] == 'eagle-installer':
|
||||
print(a['id'])
|
||||
" 2>/dev/null)
|
||||
if [ -z "$ids" ]; then
|
||||
break
|
||||
fi
|
||||
for id in $ids; do
|
||||
echo "Deleting artifact ID: $id"
|
||||
curl -s -X DELETE -H "Authorization: Bearer $GITHUB_TOKEN" \
|
||||
"https://api.github.com/repos/${{ github.repository }}/actions/artifacts/$id" || true
|
||||
done
|
||||
page=$((page + 1))
|
||||
done
|
||||
echo "Cleanup complete"
|
||||
echo "=================================="
|
||||
|
||||
# Update the unified manifest
|
||||
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- installer /tmp/installer_manifest.txt
|
||||
@@ -1,279 +0,0 @@
|
||||
name: iOS TestFlight
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 5 * * *' # 9 PM Pacific (UTC-8) / 10 PM PDT (UTC-7)
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
skip_upload:
|
||||
description: 'Skip TestFlight upload (build and archive only)'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
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:
|
||||
check-changes:
|
||||
# Skip nightly builds when nothing has changed since the last successful
|
||||
# scheduled run. Manual workflow_dispatch always builds.
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_build: ${{ steps.check.outputs.should_build }}
|
||||
steps:
|
||||
- name: Check for relevant changes since last successful run
|
||||
id: check
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
if (context.eventName === 'workflow_dispatch') {
|
||||
core.setOutput('should_build', 'true');
|
||||
console.log('Manual trigger — building');
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: { workflow_runs: runs } } = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'ios_testflight.yml',
|
||||
status: 'success',
|
||||
event: 'schedule',
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
if (runs.length === 0) {
|
||||
core.setOutput('should_build', 'true');
|
||||
console.log('No previous successful scheduled run — building');
|
||||
return;
|
||||
}
|
||||
|
||||
const lastSha = runs[0].head_sha;
|
||||
console.log(`Last successful scheduled run: ${lastSha}`);
|
||||
console.log(`Current SHA: ${context.sha}`);
|
||||
|
||||
if (lastSha === context.sha) {
|
||||
core.setOutput('should_build', 'false');
|
||||
console.log('No changes since last successful run — skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Paths that can affect the iOS Unity build.
|
||||
const relevantPrefixes = [
|
||||
'src/main/csharp/', // Unity project
|
||||
'src/main/protobuf/', // Generates C# code
|
||||
'src/main/resources/', // Game data & maps
|
||||
'scripts/', // Build scripts
|
||||
'ci/', // CI scripts & workflows
|
||||
'.github/workflows/ios_testflight.yml',
|
||||
'MODULE.bazel',
|
||||
'.bazelrc',
|
||||
];
|
||||
|
||||
const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
basehead: `${lastSha}...${context.sha}`,
|
||||
per_page: 1, // We only need the file list, not patches
|
||||
});
|
||||
|
||||
// If the diff is too large, build to be safe
|
||||
if (comparison.files === undefined) {
|
||||
core.setOutput('should_build', 'true');
|
||||
console.log('Could not retrieve file list — building to be safe');
|
||||
return;
|
||||
}
|
||||
|
||||
const relevant = comparison.files.filter(f =>
|
||||
relevantPrefixes.some(p => f.filename.startsWith(p))
|
||||
);
|
||||
|
||||
if (relevant.length > 0) {
|
||||
core.setOutput('should_build', 'true');
|
||||
console.log(`${relevant.length} relevant file(s) changed:`);
|
||||
relevant.slice(0, 20).forEach(f => console.log(` ${f.filename}`));
|
||||
if (relevant.length > 20) console.log(` ... and ${relevant.length - 20} more`);
|
||||
} else {
|
||||
core.setOutput('should_build', 'false');
|
||||
console.log(`${comparison.files.length} file(s) changed, none in relevant paths — skipping`);
|
||||
}
|
||||
|
||||
build-unity:
|
||||
needs: check-changes
|
||||
if: needs.check-changes.outputs.should_build == 'true'
|
||||
runs-on: [self-hosted, macOS, testflight]
|
||||
|
||||
steps:
|
||||
- name: Prune stale PR refs
|
||||
run: |
|
||||
# Self-hosted runners persist .git between runs. When a PR is updated,
|
||||
# old local refs (refs/remotes/pull/*/merge) may point to commits whose
|
||||
# objects were never fetched or have been pruned. Remove these stale refs
|
||||
# before checkout to prevent "missing object" errors.
|
||||
if [ -d ".git" ]; then
|
||||
echo "Pruning stale PR refs..."
|
||||
git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
|
||||
xargs -r git update-ref -d 2>/dev/null || true
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false # Fetch LFS after checkout to avoid stale ref issues
|
||||
clean: false # Library/ persists between runs on self-hosted runners
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Clean stale files
|
||||
run: |
|
||||
git clean -ffd
|
||||
rm -rf src/main/csharp/net/eagle0/clients/unity/eagle0/Library/Bee/
|
||||
|
||||
- name: Fetch LFS files
|
||||
run: ./ci/github_actions/fetch_lfs.sh
|
||||
|
||||
- name: Ensure Unity version installed
|
||||
run: ./ci/github_actions/ensure_unity_installed.sh ios
|
||||
|
||||
- name: Build iOS Unity Project
|
||||
id: build
|
||||
run: |
|
||||
./ci/github_actions/build_unity_ios.sh "$EAGLE0_BUILD_DIR/eagle0iOS"
|
||||
|
||||
- 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: Purge CDN cache for iOS addressables
|
||||
if: success()
|
||||
env:
|
||||
DO_CDN_PAT: ${{ secrets.DO_CDN_PAT }}
|
||||
run: |
|
||||
curl -s -X DELETE "https://api.digitalocean.com/v2/cdn/endpoints/8c98df29-6f0c-4704-8e82-ca40a2b81aa8/cache" \
|
||||
-H "Authorization: Bearer $DO_CDN_PAT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"files": ["addressables/iOS/*"]}' \
|
||||
--fail || echo "Warning: CDN purge failed (non-fatal)"
|
||||
|
||||
- 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: 3
|
||||
|
||||
archive-and-upload:
|
||||
needs: build-unity
|
||||
runs-on: [self-hosted, macOS, testflight]
|
||||
|
||||
steps:
|
||||
- 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 }}
|
||||
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
|
||||
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
|
||||
run: |
|
||||
# Write API key to file for xcodebuild
|
||||
API_KEY_PATH=$(mktemp)
|
||||
echo "$APP_STORE_CONNECT_API_KEY" > "$API_KEY_PATH"
|
||||
export APP_STORE_CONNECT_API_KEY_PATH="$API_KEY_PATH"
|
||||
|
||||
chmod +x ./ci/github_actions/archive_ios.sh
|
||||
./ci/github_actions/archive_ios.sh "$EAGLE0_BUILD_DIR/eagle0iOS" "$EAGLE0_BUILD_DIR/archive" "$APPLE_TEAM_ID" "$PROFILE_UUID"
|
||||
|
||||
rm -f "$API_KEY_PATH"
|
||||
|
||||
- name: Upload to TestFlight
|
||||
if: ${{ github.event.inputs.skip_upload != 'true' }}
|
||||
env:
|
||||
# App Store Connect API Key (replaces deprecated altool with Apple ID)
|
||||
# Create at: https://appstoreconnect.apple.com/access/api
|
||||
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
|
||||
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
|
||||
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
# Write API key to file (xcodebuild needs a file path)
|
||||
API_KEY_PATH=$(mktemp)
|
||||
echo "$APP_STORE_CONNECT_API_KEY" > "$API_KEY_PATH"
|
||||
export APP_STORE_CONNECT_API_KEY_PATH="$API_KEY_PATH"
|
||||
|
||||
chmod +x ./ci/github_actions/upload_testflight.sh
|
||||
./ci/github_actions/upload_testflight.sh "$EAGLE0_BUILD_DIR/archive/eagle0.xcarchive" "$APPLE_TEAM_ID" "$PROFILE_UUID"
|
||||
|
||||
# Cleanup
|
||||
rm -f "$API_KEY_PATH"
|
||||
|
||||
- name: Upload IPA artifact
|
||||
# Only keep artifact if we skipped TestFlight upload (for debugging)
|
||||
if: success() && github.event.inputs.skip_upload == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: eagle0-ios-${{ github.run_id }}
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/archive/eagle0.ipa
|
||||
retention-days: 1
|
||||
|
||||
- name: Cleanup Keychain
|
||||
if: always()
|
||||
run: |
|
||||
security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true
|
||||
- name: Cleanup build directory
|
||||
if: always()
|
||||
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
|
||||
@@ -1,409 +0,0 @@
|
||||
name: Mac Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- ".github/workflows/mac_build.yml"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/**"
|
||||
- "src/main/protobuf/net/eagle0/common/**"
|
||||
- "src/main/protobuf/net/eagle0/shardok/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/api/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/common/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/views/**"
|
||||
- "src/main/go/net/eagle0/build/mac_build_handler/**"
|
||||
- "scripts/build_protos.sh"
|
||||
- "scripts/build_mac_plugin.sh"
|
||||
- "scripts/inject_sparkle.sh"
|
||||
- "scripts/codesign_mac_app.sh"
|
||||
- "scripts/notarize_submit.sh"
|
||||
- "scripts/notarize_wait.sh"
|
||||
- "ci/github_actions/build_mac.sh"
|
||||
- "ci/github_actions/build_unity_mac.sh"
|
||||
- "ci/github_actions/upload_addressables.sh"
|
||||
- "ci/github_actions/ensure_unity_installed.sh"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
|
||||
- "ci/mac/**"
|
||||
pull_request:
|
||||
# On PRs, only build Mac when Mac-specific files change. Shared C#/proto
|
||||
# changes are covered by the Windows build — if it passes, Mac will too.
|
||||
paths:
|
||||
- ".github/workflows/mac_build.yml"
|
||||
- "src/main/go/net/eagle0/build/mac_build_handler/**"
|
||||
- "scripts/build_mac_plugin.sh"
|
||||
- "scripts/build_sparkle_plugin.sh"
|
||||
- "src/main/objc/net/eagle0/clients/unity/sparkle/**"
|
||||
- "scripts/inject_sparkle.sh"
|
||||
- "scripts/codesign_mac_app.sh"
|
||||
- "scripts/notarize_submit.sh"
|
||||
- "scripts/notarize_wait.sh"
|
||||
- "ci/github_actions/build_mac.sh"
|
||||
- "ci/github_actions/build_unity_mac.sh"
|
||||
- "ci/mac/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
skip_signing:
|
||||
description: 'Skip code signing, notarization, and deploy (build only)'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
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]
|
||||
outputs:
|
||||
submission_id: ${{ steps.notarize-submit.outputs.submission_id }}
|
||||
should_deploy: ${{ steps.check-deploy.outputs.should_deploy }}
|
||||
|
||||
steps:
|
||||
- name: Prune stale PR refs
|
||||
run: |
|
||||
# Self-hosted runners persist .git between runs. When a PR is updated,
|
||||
# old local refs (refs/remotes/pull/*/merge) may point to commits whose
|
||||
# objects were never fetched or have been pruned. Remove these stale refs
|
||||
# before checkout to prevent "missing object" errors.
|
||||
if [ -d ".git" ]; then
|
||||
echo "Pruning stale PR refs..."
|
||||
git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
|
||||
xargs -r git update-ref -d 2>/dev/null || true
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false # Fetch LFS after checkout to avoid stale ref issues
|
||||
clean: false # Library/ persists between runs on self-hosted runners
|
||||
fetch-depth: 0 # For version numbering from git history
|
||||
|
||||
- name: Clean stale files
|
||||
run: |
|
||||
git clean -ffd
|
||||
# Only clear Bee/ when C# files were added/deleted/renamed (structural
|
||||
# changes that stale the DAG). Content-only modifications are handled by
|
||||
# Bee's incremental compilation. See persist_library.sh for background.
|
||||
BEE_DIR="src/main/csharp/net/eagle0/clients/unity/eagle0/Library/Bee"
|
||||
SHA_FILE="src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha"
|
||||
if [ -f "$SHA_FILE" ] && [ -d "$BEE_DIR" ]; then
|
||||
LAST_SHA=$(cat "$SHA_FILE")
|
||||
if git diff --diff-filter=ADR --name-only "$LAST_SHA" HEAD -- '*.cs' '*.csproj' '*.asmdef' 2>/dev/null | grep -q .; then
|
||||
echo "C# files added/deleted/renamed since $LAST_SHA — clearing Bee/"
|
||||
rm -rf "$BEE_DIR"
|
||||
else
|
||||
echo "No structural C# changes since $LAST_SHA — keeping Bee/"
|
||||
fi
|
||||
else
|
||||
echo "No previous build SHA or no Bee/ — clearing Bee/ as safe default"
|
||||
rm -rf "$BEE_DIR"
|
||||
fi
|
||||
|
||||
- name: Fetch LFS files
|
||||
run: ./ci/github_actions/fetch_lfs.sh
|
||||
|
||||
- name: Ensure Unity version installed
|
||||
run: ./ci/github_actions/ensure_unity_installed.sh mac
|
||||
|
||||
- name: Sync Bazel Xcode config
|
||||
run: ./scripts/sync_bazel_xcode.sh
|
||||
|
||||
- name: Build Mac Unity
|
||||
run: ./ci/github_actions/build_unity_mac.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC"
|
||||
|
||||
- name: Save build SHA for Bee/ cache invalidation
|
||||
if: success()
|
||||
run: git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
|
||||
|
||||
- name: Upload Addressables to CDN
|
||||
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: ./ci/github_actions/upload_addressables.sh StandaloneOSX
|
||||
|
||||
- name: Inject Sparkle Framework
|
||||
if: success()
|
||||
env:
|
||||
SPARKLE_EDDSA_PUBLIC_KEY: ${{ secrets.SPARKLE_EDDSA_PUBLIC_KEY }}
|
||||
run: |
|
||||
chmod +x ./scripts/inject_sparkle.sh
|
||||
./scripts/inject_sparkle.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app"
|
||||
|
||||
- name: Check if should deploy
|
||||
id: check-deploy
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ github.event.inputs.skip_signing }}" == "true" ]]; then
|
||||
echo "should_deploy=false" >> $GITHUB_OUTPUT
|
||||
elif [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
|
||||
echo "should_deploy=true" >> $GITHUB_OUTPUT
|
||||
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
echo "should_deploy=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "should_deploy=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Import Code Signing Certificate
|
||||
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
|
||||
env:
|
||||
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
|
||||
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
|
||||
run: |
|
||||
# Generate random keychain password (only used within this workflow run)
|
||||
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
|
||||
echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> $GITHUB_ENV
|
||||
|
||||
# Decode certificate
|
||||
echo "$MACOS_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"
|
||||
|
||||
# Import certificate
|
||||
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
|
||||
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
|
||||
|
||||
- name: Code Sign App
|
||||
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
|
||||
env:
|
||||
SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
|
||||
run: |
|
||||
chmod +x ./scripts/codesign_mac_app.sh
|
||||
./scripts/codesign_mac_app.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app" "ci/mac/eagle0.entitlements"
|
||||
|
||||
- name: Submit for Notarization
|
||||
id: notarize-submit
|
||||
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
|
||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
chmod +x ./scripts/notarize_submit.sh
|
||||
./scripts/notarize_submit.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cleanup Keychain
|
||||
if: always()
|
||||
run: |
|
||||
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 ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
|
||||
ditto -c -k --keepParent eagle0.app eagle0.app.zip
|
||||
|
||||
- name: Upload signed app
|
||||
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: signed-mac-app-${{ github.run_id }}
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app.zip
|
||||
retention-days: 1
|
||||
|
||||
- name: Archive Build Log
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: editor_mac.log
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_mac.log
|
||||
retention-days: 3
|
||||
- name: Cleanup build directory
|
||||
if: always()
|
||||
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
|
||||
|
||||
wait-notarization:
|
||||
needs: build-and-sign
|
||||
if: needs.build-and-sign.outputs.should_deploy == 'true'
|
||||
runs-on: [self-hosted, macOS, notarize]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: scripts
|
||||
|
||||
- name: Clean download directory
|
||||
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: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
|
||||
|
||||
- name: Unzip signed app
|
||||
run: |
|
||||
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
|
||||
ditto -x -k eagle0.app.zip .
|
||||
rm eagle0.app.zip
|
||||
ls -la ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app/
|
||||
|
||||
- name: Wait for Notarization and Staple
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
|
||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
chmod +x ./scripts/notarize_wait.sh
|
||||
./scripts/notarize_wait.sh "${{ needs.build-and-sign.outputs.submission_id }}" "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app"
|
||||
|
||||
- name: Zip notarized app for artifact
|
||||
run: |
|
||||
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
|
||||
rm -f eagle0.app.zip
|
||||
ditto -c -k --keepParent eagle0.app eagle0.app.zip
|
||||
|
||||
- name: Upload notarized app
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: notarized-mac-app-${{ github.run_id }}
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app.zip
|
||||
retention-days: 1
|
||||
- name: Cleanup build directory
|
||||
if: always()
|
||||
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
|
||||
|
||||
deploy:
|
||||
needs: [build-and-sign, wait-notarization]
|
||||
if: needs.build-and-sign.outputs.should_deploy == 'true'
|
||||
runs-on: [self-hosted, macOS, unity-mac]
|
||||
outputs:
|
||||
deployed_version: ${{ steps.deploy-mac.outputs.deployed_version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # For version numbering
|
||||
|
||||
- name: Clean download directory
|
||||
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: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
|
||||
|
||||
- name: Unzip notarized app
|
||||
run: |
|
||||
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
|
||||
ditto -x -k eagle0.app.zip .
|
||||
rm eagle0.app.zip
|
||||
|
||||
- name: Sync Bazel Xcode config
|
||||
run: ./scripts/sync_bazel_xcode.sh
|
||||
|
||||
- name: Deploy Mac Build
|
||||
id: deploy-mac
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
SPARKLE_EDDSA_PRIVATE_KEY: ${{ secrets.SPARKLE_EDDSA_PRIVATE_KEY }}
|
||||
run: |
|
||||
# Write private key to temp file for signing
|
||||
SPARKLE_PRIVATE_KEY_PATH="/tmp/sparkle_private_key"
|
||||
echo "$SPARKLE_EDDSA_PRIVATE_KEY" > "$SPARKLE_PRIVATE_KEY_PATH"
|
||||
chmod 600 "$SPARKLE_PRIVATE_KEY_PATH"
|
||||
|
||||
# Install dmgbuild (creates .DS_Store programmatically, no AppleScript needed)
|
||||
pip3 install dmgbuild
|
||||
|
||||
# Background image for styled DMG
|
||||
BACKGROUND_PATH="$(pwd)/ci/mac/dmg/background.png"
|
||||
|
||||
# Read version from the built app's Info.plist to ensure appcast matches the actual app
|
||||
APP_PATH="${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app"
|
||||
BUILD_NUMBER=$(/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$APP_PATH/Contents/Info.plist")
|
||||
VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP_PATH/Contents/Info.plist")
|
||||
|
||||
bazel run //src/main/go/net/eagle0/build/mac_build_handler:mac_build_handler -- \
|
||||
"${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app" \
|
||||
"$VERSION" \
|
||||
"$BUILD_NUMBER" \
|
||||
"$BACKGROUND_PATH" \
|
||||
"$SPARKLE_PRIVATE_KEY_PATH"
|
||||
|
||||
rm "$SPARKLE_PRIVATE_KEY_PATH"
|
||||
|
||||
echo "deployed_version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# Artifact cleanup is handled by the cleanup job on ubuntu-latest
|
||||
- name: Cleanup build directory
|
||||
if: always()
|
||||
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
|
||||
|
||||
notify-mac:
|
||||
needs: deploy
|
||||
if: needs.deploy.outputs.deployed_version != ''
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Purge CDN cache and notify clients
|
||||
env:
|
||||
NOTIFY_SECRET: ${{ secrets.EAGLE_NOTIFY_SECRET }}
|
||||
DO_CDN_PAT: ${{ secrets.DO_CDN_PAT }}
|
||||
run: |
|
||||
# Purge CDN cache so clients get the latest files immediately
|
||||
curl -s -X DELETE "https://api.digitalocean.com/v2/cdn/endpoints/8c98df29-6f0c-4704-8e82-ca40a2b81aa8/cache" \
|
||||
-H "Authorization: Bearer $DO_CDN_PAT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"files": ["mac/*", "addressables/StandaloneOSX/*"]}' \
|
||||
--fail || echo "Warning: CDN purge failed (non-fatal)"
|
||||
|
||||
curl -X POST "https://admin.eagle0.net/notify-update?platform=mac&version=${{ needs.deploy.outputs.deployed_version }}&required=false" \
|
||||
-H "X-Notify-Secret: $NOTIFY_SECRET" \
|
||||
--fail --silent --show-error || echo "Warning: Failed to notify clients (non-fatal)"
|
||||
|
||||
# Cleanup job runs regardless of success/failure to prevent artifact accumulation
|
||||
cleanup:
|
||||
needs: [build-and-sign, wait-notarization, deploy, notify-mac]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Delete this run's Mac app artifacts
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# Delete this run's artifacts (names include run ID to avoid conflicts)
|
||||
for artifact_name in signed-mac-app-${{ github.run_id }} notarized-mac-app-${{ github.run_id }}; do
|
||||
echo "Deleting artifact: $artifact_name"
|
||||
artifact_id=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" \
|
||||
-q ".artifacts[] | select(.name == \"$artifact_name\") | .id")
|
||||
if [ -n "$artifact_id" ]; then
|
||||
echo "Deleting artifact ID: $artifact_id"
|
||||
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id" || true
|
||||
fi
|
||||
done
|
||||
echo "Cleanup complete"
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Mac History Editor Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- ".github/workflows/mac_history_build.yml"
|
||||
- "src/main/swift/net/eagle0/EagleGameHistoryViewer/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/mac_history_build.yml"
|
||||
- "src/main/swift/net/eagle0/EagleGameHistoryViewer/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
mac-history-build:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
clean: false
|
||||
- name: Build the mac history
|
||||
run: ./ci/github_actions/build_mac_history.sh
|
||||
@@ -1,92 +0,0 @@
|
||||
name: Cleanup Old Container Images
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run daily at 3am UTC
|
||||
- cron: '0 3 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Dry run (show what would be deleted without deleting)'
|
||||
required: true
|
||||
default: 'true'
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install doctl
|
||||
uses: digitalocean/action-doctl@v2
|
||||
with:
|
||||
token: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
|
||||
- name: Cleanup old images
|
||||
env:
|
||||
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true' }}
|
||||
run: |
|
||||
set -e
|
||||
|
||||
RETENTION_DAYS=5
|
||||
CUTOFF_DATE=$(date -d "-${RETENTION_DAYS} days" +%s)
|
||||
REGISTRY="eagle0"
|
||||
|
||||
echo "Cleaning up images older than ${RETENTION_DAYS} days"
|
||||
echo "Cutoff date: $(date -d "@${CUTOFF_DATE}" -Iseconds)"
|
||||
echo "Dry run: ${DRY_RUN}"
|
||||
echo ""
|
||||
|
||||
# List of repositories to clean
|
||||
# Use tail to skip header row in case --no-header doesn't work
|
||||
REPOS=$(doctl registry repository list-v2 --format Name --no-header | grep -v '^Name$' | grep -v '^$')
|
||||
|
||||
for REPO in $REPOS; do
|
||||
echo "=== Processing repository: ${REPO} ==="
|
||||
|
||||
# Get all manifests with their tags and dates using JSON output for reliable parsing
|
||||
MANIFESTS_JSON=$(doctl registry repository list-manifests "${REPO}" --output json 2>/dev/null || echo "[]")
|
||||
|
||||
if [ "$MANIFESTS_JSON" = "[]" ] || [ -z "$MANIFESTS_JSON" ]; then
|
||||
echo " No manifests found"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Parse JSON and process each manifest
|
||||
echo "$MANIFESTS_JSON" | jq -r '.[] | "\(.digest) \(.updated_at) \(.tags // [] | join(","))"' | while read -r DIGEST UPDATED_AT TAGS; do
|
||||
# Skip if no digest or if it doesn't look like a valid digest (sha256:...)
|
||||
if [ -z "$DIGEST" ] || ! echo "$DIGEST" | grep -q '^sha256:'; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Parse the date (ISO 8601 format from JSON)
|
||||
MANIFEST_DATE=$(date -d "$UPDATED_AT" +%s 2>/dev/null || echo "0")
|
||||
|
||||
# Skip protected tags (latest, arm64-latest)
|
||||
if echo ",$TAGS," | grep -qE ',(latest|arm64-latest),'; then
|
||||
echo " KEEP: ${DIGEST:0:20}... (protected tag: $TAGS)"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if older than cutoff
|
||||
if [ "$MANIFEST_DATE" -lt "$CUTOFF_DATE" ]; then
|
||||
echo " DELETE: ${DIGEST:0:20}... (updated: $UPDATED_AT, tags: $TAGS)"
|
||||
if [ "$DRY_RUN" != "true" ]; then
|
||||
doctl registry repository delete-manifest "${REPO}" "$DIGEST" --force
|
||||
fi
|
||||
else
|
||||
echo " KEEP: ${DIGEST:0:20}... (updated: $UPDATED_AT, tags: $TAGS)"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
done
|
||||
|
||||
- name: Run garbage collection
|
||||
if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'false')
|
||||
run: |
|
||||
echo "Starting garbage collection..."
|
||||
doctl registry garbage-collection start --force
|
||||
echo "Garbage collection started. It may take a few minutes to complete."
|
||||
@@ -1,241 +0,0 @@
|
||||
name: Shardok ARM64 Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 'src/main/cpp/**'
|
||||
- 'src/main/protobuf/net/eagle0/shardok/**'
|
||||
- 'src/main/protobuf/net/eagle0/common/**'
|
||||
- 'src/main/resources/net/eagle0/shardok/**'
|
||||
- 'ci/BUILD.bazel'
|
||||
- 'MODULE.bazel'
|
||||
- '.bazelrc'
|
||||
- '.github/workflows/shardok_arm64_build.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push_images:
|
||||
description: 'Push images to container registry'
|
||||
required: true
|
||||
default: 'true'
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: shardok-arm64-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-shardok-arm64:
|
||||
runs-on: [self-hosted, bazel]
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Build Shardok ARM64 binary (cross-compile for Linux ARM64)
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
echo "=== Building shardok-server binary for linux-aarch64 ==="
|
||||
bazel build \
|
||||
--platforms=//:linux_arm64 \
|
||||
--extra_toolchains=@llvm_toolchain_linux_arm64//:all \
|
||||
//src/main/cpp/net/eagle0/shardok:shardok-server
|
||||
|
||||
LINUX_BIN="bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server"
|
||||
echo "=== Checking binary at: $LINUX_BIN ==="
|
||||
|
||||
if [ ! -f "$LINUX_BIN" ]; then
|
||||
echo "ERROR: Binary not found at $LINUX_BIN"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify it's ELF (Linux) not Mach-O (macOS)
|
||||
echo "=== Verifying binary format ==="
|
||||
MAGIC=$(head -c 4 "$LINUX_BIN" | xxd -p)
|
||||
echo "Binary magic bytes: $MAGIC"
|
||||
|
||||
if [ "$MAGIC" = "7f454c46" ]; then
|
||||
echo "SUCCESS: Binary is ELF format (Linux)"
|
||||
# Check if it's ARM64 (e_machine = 0xB7 = 183 for aarch64)
|
||||
# od -tx2 reads as a 16-bit value in host byte order (little-endian on macOS),
|
||||
# so the little-endian ELF bytes b7 00 are displayed as 00b7.
|
||||
E_MACHINE=$(od -An -j18 -N2 -tx2 "$LINUX_BIN" | tr -d ' ')
|
||||
echo "ELF e_machine: $E_MACHINE"
|
||||
if [ "$E_MACHINE" = "00b7" ]; then
|
||||
echo "SUCCESS: Binary is ARM64 (aarch64)"
|
||||
else
|
||||
echo "WARNING: Binary e_machine is $E_MACHINE (expected 00b7 for aarch64)"
|
||||
fi
|
||||
elif [ "$MAGIC" = "cfaeedfe" ] || [ "$MAGIC" = "cffaedfe" ]; then
|
||||
echo "ERROR: Binary is Mach-O format (macOS) - cross-compilation failed!"
|
||||
exit 1
|
||||
else
|
||||
echo "WARNING: Unknown binary format: $MAGIC"
|
||||
file "$LINUX_BIN" || true
|
||||
fi
|
||||
|
||||
- name: Build Shardok ARM64 Docker image
|
||||
id: build-shardok
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
bazel build \
|
||||
--platforms=//:linux_arm64 \
|
||||
--extra_toolchains=@llvm_toolchain_linux_arm64//:all \
|
||||
//ci:shardok_server_image_arm64
|
||||
|
||||
IMAGE_PATH=$(readlink -f bazel-bin/ci/shardok_server_image_arm64)
|
||||
echo "Image path: $IMAGE_PATH"
|
||||
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
# Verify the binary inside the tar layer is ARM64 ELF
|
||||
echo "=== Verifying binary in image tar ==="
|
||||
BINARY_TAR="bazel-bin/ci/shardok_binary_layer_arm64.tar"
|
||||
if [ -f "$BINARY_TAR" ]; then
|
||||
echo "Checking binary in $BINARY_TAR"
|
||||
MAGIC=$(tar -xOf "$BINARY_TAR" app/shardok-server 2>/dev/null | head -c 4 | xxd -p)
|
||||
echo "Binary magic in tar: $MAGIC"
|
||||
if [ "$MAGIC" = "7f454c46" ]; then
|
||||
echo "SUCCESS: Binary in tar is ELF format (Linux)"
|
||||
else
|
||||
echo "ERROR: Binary in tar is NOT ELF format!"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "WARNING: Could not find $BINARY_TAR"
|
||||
fi
|
||||
|
||||
- name: Login to DigitalOcean Container Registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
run: |
|
||||
AUTH=$(echo -n "${DO_REGISTRY_TOKEN}:${DO_REGISTRY_TOKEN}" | base64)
|
||||
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
|
||||
- name: Push Shardok ARM64 image to DigitalOcean
|
||||
id: push-shardok
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
CROSS_IMAGE="${{ steps.build-shardok.outputs.image_path }}"
|
||||
echo "Using cross-compiled image: $CROSS_IMAGE"
|
||||
|
||||
if [ -z "$CROSS_IMAGE" ] || [ ! -d "$CROSS_IMAGE" ]; then
|
||||
echo "ERROR: Cross-compiled image not found at: $CROSS_IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get crane (cached across runs to avoid rebuilding bazel target,
|
||||
# which would discard the ARM64 analysis cache due to platform change)
|
||||
CRANE_VERSION="v0.20.2"
|
||||
CRANE_DIR="${HOME}/.local/bin"
|
||||
CRANE="${CRANE_DIR}/crane"
|
||||
if [ ! -x "$CRANE" ] || ! "$CRANE" version 2>/dev/null | grep -q "0.20.2"; then
|
||||
echo "Installing crane ${CRANE_VERSION}..."
|
||||
mkdir -p "$CRANE_DIR"
|
||||
curl -sL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Darwin_arm64.tar.gz" | tar xzf - -C "$CRANE_DIR" crane
|
||||
chmod +x "$CRANE"
|
||||
else
|
||||
echo "Using cached crane at $CRANE"
|
||||
fi
|
||||
|
||||
# Push with arm64-prefixed SHA tag (same repo as x86, different tag)
|
||||
GIT_SHA=$(git rev-parse --short=8 HEAD)
|
||||
IMAGE_TAG="registry.digitalocean.com/eagle0/shardok-server:arm64-${GIT_SHA}"
|
||||
echo "Pushing shardok ARM64 image: $IMAGE_TAG"
|
||||
$CRANE push "$CROSS_IMAGE" "$IMAGE_TAG"
|
||||
|
||||
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Also update :arm64-latest tag for convenience
|
||||
echo "Copying to :arm64-latest tag"
|
||||
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/shardok-server:arm64-latest"
|
||||
|
||||
echo "=== Push complete ==="
|
||||
echo "Image: $IMAGE_TAG"
|
||||
echo "Also tagged as: registry.digitalocean.com/eagle0/shardok-server:arm64-latest"
|
||||
|
||||
deploy-hetzner:
|
||||
runs-on: [self-hosted, bazel]
|
||||
needs: [build-shardok-arm64]
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
environment: production
|
||||
env:
|
||||
SHARDOK_IMAGE: ${{ needs.build-shardok-arm64.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.HETZNER_SSH_KEY }}" > ~/.ssh/hetzner_deploy
|
||||
chmod 600 ~/.ssh/hetzner_deploy
|
||||
# Add host key to known_hosts to avoid prompt
|
||||
ssh-keyscan -H ${{ secrets.HETZNER_IP }} >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
- name: Deploy to Hetzner
|
||||
run: |
|
||||
ssh -i ~/.ssh/hetzner_deploy -o StrictHostKeyChecking=accept-new deploy@${{ secrets.HETZNER_IP }} << 'ENDSSH'
|
||||
set -ex
|
||||
cd /opt/eagle0
|
||||
|
||||
# Login to registry
|
||||
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
|
||||
|
||||
echo "Deploying Shardok ARM64: ${{ needs.build-shardok-arm64.outputs.image_tag }}"
|
||||
|
||||
# Pre-warm NAT64 path to DigitalOcean registry (IPv6-only Hetzner → IPv4 registry)
|
||||
curl -sf --max-time 10 https://registry.digitalocean.com/v2/ > /dev/null 2>&1 || true
|
||||
|
||||
# Pull the new image (retry up to 3 times for NAT64 connectivity flakiness)
|
||||
for attempt in 1 2 3; do
|
||||
echo "Pull attempt $attempt..."
|
||||
if docker pull "${{ needs.build-shardok-arm64.outputs.image_tag }}"; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "ERROR: docker pull failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
echo "Pull failed, retrying in 10s..."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
# Stop and remove any container using port 40042 or named shardok*
|
||||
docker ps -q --filter "publish=40042" | xargs -r docker stop
|
||||
docker ps -aq --filter "name=shardok" | xargs -r docker rm -f
|
||||
docker ps -aq --filter "publish=40042" | xargs -r docker rm -f
|
||||
|
||||
# Run new container
|
||||
docker run -d \
|
||||
--name shardok-ai \
|
||||
--restart unless-stopped \
|
||||
-p 40042:40042 \
|
||||
-v /opt/eagle0/data:/data \
|
||||
-v /etc/shardok:/etc/shardok:ro \
|
||||
-v /etc/letsencrypt:/etc/letsencrypt:ro \
|
||||
-v /usr/local/share/eagle0:/usr/local/share/eagle0:ro \
|
||||
-e SHARDOK_RESOURCES_PATH=/app/resources \
|
||||
-e SHARDOK_MAPS_PATH=/app/resources/maps \
|
||||
"${{ needs.build-shardok-arm64.outputs.image_tag }}"
|
||||
|
||||
# Wait and verify
|
||||
sleep 5
|
||||
docker ps | grep shardok-ai
|
||||
|
||||
# Cleanup old images
|
||||
docker image prune -f
|
||||
|
||||
echo "=== Hetzner deployment complete ==="
|
||||
ENDSSH
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/hetzner_deploy
|
||||
@@ -1,7 +1,17 @@
|
||||
name: Shardok Build
|
||||
|
||||
on:
|
||||
# Main pushes are covered by shardok_arm64_build.yml which builds the same target
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 'src/main/cpp/**'
|
||||
- 'src/main/proto/net/eagle0/shardok/**'
|
||||
- 'src/main/proto/net/eagle0/common/**'
|
||||
- 'src/main/go/net/eagle0/build/**'
|
||||
- 'WORKSPACE'
|
||||
- 'MODULE.bazel'
|
||||
- 'BUILD.bazel'
|
||||
- '.github/workflows/shardok_build.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/main/cpp/**'
|
||||
@@ -11,19 +21,14 @@ on:
|
||||
- 'WORKSPACE'
|
||||
- 'MODULE.bazel'
|
||||
- 'BUILD.bazel'
|
||||
- '.bazelrc'
|
||||
- '.github/workflows/shardok_build.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: [self-hosted, bazel]
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -6,170 +6,67 @@ on:
|
||||
paths:
|
||||
- ".github/workflows/unity_build.yml"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/**"
|
||||
- "src/main/protobuf/net/eagle0/common/**"
|
||||
- "src/main/protobuf/net/eagle0/shardok/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/api/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/common/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/views/**"
|
||||
- "src/main/proto/**"
|
||||
- "scripts/build_protos.sh"
|
||||
- "scripts/build_plugins.sh"
|
||||
- "scripts/build_windows_plugin.sh"
|
||||
- "ci/github_actions/build_unity.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/github_actions/restore_library.sh"
|
||||
- "ci/github_actions/persist_library.sh"
|
||||
- "MODULE.bazel"
|
||||
- "WORKSPACE"
|
||||
workflow_dispatch:
|
||||
- "src/main/proto/net/eagle0/eagle/**/BUILD.bazel"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/unity_build.yml"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/**"
|
||||
- "src/main/protobuf/net/eagle0/common/**"
|
||||
- "src/main/protobuf/net/eagle0/shardok/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/api/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/common/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/views/**"
|
||||
- "src/main/proto/**"
|
||||
- "scripts/build_protos.sh"
|
||||
- "scripts/build_plugins.sh"
|
||||
- "scripts/build_windows_plugin.sh"
|
||||
- "ci/github_actions/build_unity.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/github_actions/restore_library.sh"
|
||||
- "ci/github_actions/persist_library.sh"
|
||||
- "MODULE.bazel"
|
||||
- "WORKSPACE"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
- "src/main/proto/**/BUILD.bazel"
|
||||
|
||||
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]
|
||||
outputs:
|
||||
deployed_version: ${{ steps.get-version.outputs.deployed_version }}
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Prune stale PR refs
|
||||
run: |
|
||||
# Self-hosted runners persist .git between runs. When a PR is updated,
|
||||
# old local refs (refs/remotes/pull/*/merge) may point to commits whose
|
||||
# objects were never fetched or have been pruned. Remove these stale refs
|
||||
# before checkout to prevent "missing object" errors.
|
||||
if [ -d ".git" ]; then
|
||||
echo "Pruning stale PR refs..."
|
||||
git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
|
||||
xargs -r git update-ref -d 2>/dev/null || true
|
||||
fi
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false # Fetch LFS after checkout to avoid stale ref issues
|
||||
clean: false # Library/ persists between runs on self-hosted runners
|
||||
|
||||
- name: Clean stale files
|
||||
run: |
|
||||
git clean -ffd
|
||||
# Only clear Bee/ when C# files were added/deleted/renamed (structural
|
||||
# changes that stale the DAG). Content-only modifications are handled by
|
||||
# Bee's incremental compilation. See persist_library.sh for background.
|
||||
BEE_DIR="src/main/csharp/net/eagle0/clients/unity/eagle0/Library/Bee"
|
||||
SHA_FILE="src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha"
|
||||
if [ -f "$SHA_FILE" ] && [ -d "$BEE_DIR" ]; then
|
||||
LAST_SHA=$(cat "$SHA_FILE")
|
||||
if git diff --diff-filter=ADR --name-only "$LAST_SHA" HEAD -- '*.cs' '*.csproj' '*.asmdef' 2>/dev/null | grep -q .; then
|
||||
echo "C# files added/deleted/renamed since $LAST_SHA — clearing Bee/"
|
||||
rm -rf "$BEE_DIR"
|
||||
else
|
||||
echo "No structural C# changes since $LAST_SHA — keeping Bee/"
|
||||
fi
|
||||
else
|
||||
echo "No previous build SHA or no Bee/ — clearing Bee/ as safe default"
|
||||
rm -rf "$BEE_DIR"
|
||||
fi
|
||||
|
||||
- name: Fetch LFS files
|
||||
run: ./ci/github_actions/fetch_lfs.sh
|
||||
- name: Ensure Unity version installed
|
||||
run: ./ci/github_actions/ensure_unity_installed.sh windows
|
||||
lfs: true
|
||||
clean: false
|
||||
- name: Pull lfs files
|
||||
run: git lfs pull
|
||||
- name: Restore Library/
|
||||
run: ./ci/github_actions/restore_library.sh
|
||||
- name: Build Windows unity
|
||||
run: ./ci/github_actions/build_unity.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0WIN"
|
||||
|
||||
- name: Save build SHA for Bee/ cache invalidation
|
||||
if: success()
|
||||
run: git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
|
||||
|
||||
- name: Upload Addressables to CDN
|
||||
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: ./ci/github_actions/upload_addressables.sh StandaloneWindows64
|
||||
run: ./ci/github_actions/build_unity.sh "/tmp/eagle0/eagle0WIN"
|
||||
- name: Persist Library/
|
||||
run: ./ci/github_actions/persist_library.sh
|
||||
- name: Deploy Windows unity
|
||||
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
||||
if: success() #&& github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: bazel run //src/main/go/net/eagle0/build/unity3d_windows_build_handler:unity3d_windows_build_handler -- "${{ env.EAGLE0_BUILD_DIR }}/eagle0WIN" "/tmp/unity_manifest.txt"
|
||||
run: bazel run //src/main/go/net/eagle0/build/unity3d_windows_build_handler:unity3d_windows_build_handler -- "/tmp/eagle0/eagle0WIN" "/tmp/unity_manifest.txt"
|
||||
|
||||
- name: Update unified manifest
|
||||
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
||||
if: success() #&& github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
|
||||
run: |
|
||||
# Write signing key to temp file (if available)
|
||||
SIGNING_ARGS=""
|
||||
if [ -n "$MANIFEST_SIGNING_KEY" ]; then
|
||||
echo "$MANIFEST_SIGNING_KEY" > /tmp/manifest_signing_key
|
||||
chmod 600 /tmp/manifest_signing_key
|
||||
SIGNING_ARGS="/tmp/manifest_signing_key"
|
||||
echo "Manifest signing key available"
|
||||
else
|
||||
echo "Warning: MANIFEST_SIGNING_KEY not set, manifest will be unsigned"
|
||||
fi
|
||||
|
||||
# Update the v2 manifest at installer/v2/eagle0_manifest.txt
|
||||
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- unity3d-v2 /tmp/unity_manifest.txt $SIGNING_ARGS
|
||||
|
||||
# Cleanup
|
||||
rm -f /tmp/manifest_signing_key
|
||||
|
||||
- name: Export deployed version
|
||||
id: get-version
|
||||
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
||||
run: |
|
||||
VERSION=$(grep "^version=" /tmp/unity_manifest.txt | cut -d= -f2 || date +%Y.%m.%d)
|
||||
echo "deployed_version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
run: bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- unity3d /tmp/unity_manifest.txt
|
||||
- name: Archive build log
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: editor_win.log
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_win.log
|
||||
retention-days: 3
|
||||
- name: Cleanup build directory
|
||||
if: always()
|
||||
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
|
||||
|
||||
notify-windows:
|
||||
needs: windows-unity
|
||||
if: needs.windows-unity.outputs.deployed_version != ''
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Notify clients of update
|
||||
env:
|
||||
NOTIFY_SECRET: ${{ secrets.EAGLE_NOTIFY_SECRET }}
|
||||
run: |
|
||||
curl -X POST "https://admin.eagle0.net/notify-update?platform=windows&version=${{ needs.windows-unity.outputs.deployed_version }}&required=false" \
|
||||
-H "X-Notify-Secret: $NOTIFY_SECRET" \
|
||||
--fail --silent --show-error || echo "Warning: Failed to notify clients (non-fatal)"
|
||||
path: /tmp/eagle0/editor_win.log
|
||||
@@ -23,7 +23,6 @@ bazel-bin
|
||||
bazel-eagle0*
|
||||
bazel-out
|
||||
bazel-testlogs
|
||||
.bazelrc.xcode
|
||||
.ijwb
|
||||
.clwb
|
||||
buildWin.sh
|
||||
@@ -38,8 +37,3 @@ scripts/refresh_name_layers/refresh_name_layers.zip
|
||||
.metals
|
||||
api_keys.txt
|
||||
|
||||
src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/GeneratedProtos/
|
||||
src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/Eagle0Protos/
|
||||
src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/Packages/com.unity.dedicated-server/
|
||||
node_modules/
|
||||
tools/map_generator/output/
|
||||
|
||||
+9
-13
@@ -5,7 +5,6 @@ repos:
|
||||
rev: v4.3.0
|
||||
hooks:
|
||||
- id: check-added-large-files
|
||||
- id: check-merge-conflict
|
||||
- id: no-commit-to-branch
|
||||
args: [--branch, main]
|
||||
- repo: https://github.com/pocc/pre-commit-hooks
|
||||
@@ -14,16 +13,7 @@ repos:
|
||||
- id: clang-format
|
||||
args: [-i, --no-diff]
|
||||
types_or: ["c++", "c#"]
|
||||
exclude: >-
|
||||
(?x)^src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/(
|
||||
Plugins|
|
||||
DungeonMonsters2D|
|
||||
Dragon|
|
||||
ithappy|
|
||||
Polytope\ Studio|
|
||||
RRFreelance-Characters|
|
||||
Raccoon
|
||||
)/
|
||||
exclude: ^src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins
|
||||
- repo: https://github.com/yoheimuta/protolint
|
||||
rev: v0.42.2
|
||||
hooks:
|
||||
@@ -42,6 +32,12 @@ repos:
|
||||
- id: gazelle
|
||||
name: gazelle
|
||||
language: system
|
||||
entry: ./scripts/pre-commit-gazelle.sh
|
||||
entry: bazel run //:gazelle
|
||||
files: '(\.go|\.proto|BUILD\.bazel|BUILD|WORKSPACE|WORKSPACE\.bazel|\.bzl)$'
|
||||
pass_filenames: false
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: update-action-result-types
|
||||
name: update-action-result-types
|
||||
language: system
|
||||
entry: ./scripts/updateActionResultTypes.sh
|
||||
files: 'src/main/protobuf/net/eagle0/eagle/common/action_result_type.proto'
|
||||
|
||||
-30
@@ -3,24 +3,6 @@ load("@io_bazel_rules_go//go:def.bzl", "nogo")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
# Platform for cross-compiling to Linux x86_64
|
||||
platform(
|
||||
name = "linux_x86_64",
|
||||
constraint_values = [
|
||||
"@platforms//os:linux",
|
||||
"@platforms//cpu:x86_64",
|
||||
],
|
||||
)
|
||||
|
||||
# Platform for cross-compiling to Linux ARM64
|
||||
platform(
|
||||
name = "linux_arm64",
|
||||
constraint_values = [
|
||||
"@platforms//os:linux",
|
||||
"@platforms//cpu:aarch64",
|
||||
],
|
||||
)
|
||||
|
||||
gazelle(name = "gazelle")
|
||||
|
||||
# gazelle:proto file
|
||||
@@ -32,15 +14,3 @@ nogo(
|
||||
vet = True,
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
# Dependency constraint tests
|
||||
# These verify architectural boundaries are maintained
|
||||
sh_test(
|
||||
name = "build_deps_test",
|
||||
srcs = ["scripts/check_build_deps.sh"],
|
||||
args = ["--ci"],
|
||||
tags = [
|
||||
"local", # Needs bazel query access
|
||||
"no-sandbox",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1,29 +1,5 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## CRITICAL BASH RULES (NEVER VIOLATE)
|
||||
|
||||
**NEVER chain bash commands.** Do not use `&&`, `||`, or `;` to combine commands. Each command must be a separate Bash tool call. Use parallel tool calls when commands are independent.
|
||||
|
||||
## CRITICAL GIT RULES (NEVER VIOLATE)
|
||||
|
||||
**NEVER use `git -C`.** Instead, cd to the repo root and run git commands from there.
|
||||
|
||||
**NEVER push directly to main/master.** No exceptions. Not for "small changes." Not for docs. Not ever.
|
||||
|
||||
**NEVER merge PRs.** You create PRs. The user merges them. No exceptions.
|
||||
|
||||
**ALWAYS use this workflow:**
|
||||
1. Create a feature branch from origin/main
|
||||
2. Commit to that branch
|
||||
3. Create a PR with `gh pr create`
|
||||
4. Wait for user to merge (DO NOT run `gh pr merge`)
|
||||
|
||||
If you catch yourself about to run `git push origin main` or `git push origin <branch>:main`, STOP. You are about to violate a critical rule. Create a PR instead.
|
||||
|
||||
If you catch yourself about to run `gh pr merge`, STOP. Only the user merges PRs.
|
||||
|
||||
---
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
@@ -109,20 +85,6 @@ bazel run gazelle # Update Go build files
|
||||
./scripts/updateActionResultTypes.sh # Update protocol buffer mappings
|
||||
```
|
||||
|
||||
### Pre-Commit Checklist
|
||||
|
||||
**MANDATORY: Before running `git commit`, verify:**
|
||||
|
||||
1. **If you modified any BUILD.bazel file:** Run `bazel run gazelle` and stage any changes it makes
|
||||
2. **If you modified C++ or C# files:** Run `clang-format -i` on the modified files
|
||||
3. **If you modified Scala files:** scalafmt will run automatically via pre-commit hook
|
||||
4. **ALWAYS run the full test suite for your language changes before pushing:**
|
||||
- For Scala changes: `bazel test //src/test/scala/...`
|
||||
- For C++ changes: `bazel test //src/test/cpp/...`
|
||||
- This catches exhaustive pattern match errors and other compile-time failures that single-target builds miss
|
||||
|
||||
The pre-commit hook runs gazelle but only checks if it succeeds - it does NOT verify the BUILD files are in canonical format. The `gazelle_test` will fail if deps are not alphabetically sorted. **Always run gazelle manually after BUILD file changes.**
|
||||
|
||||
### Code Formatting
|
||||
|
||||
```bash
|
||||
@@ -231,9 +193,6 @@ to be used for different players or game situations within the same server proce
|
||||
- Real-time bidirectional streaming with server via `PersistentClientConnection.cs`
|
||||
- Strategic map UI in `Assets/Eagle/`, tactical battle UI in `Assets/Shardok/`
|
||||
- Seamless transition between strategic gameplay and hex-based tactical combat
|
||||
- **NEVER add defensive null checks on Unity Inspector fields** - these hide configuration bugs. If a field isn't
|
||||
linked in the editor, it should throw a NullReferenceException so the problem is immediately obvious. Silently
|
||||
skipping code when a required field is null makes bugs harder to find.
|
||||
|
||||
**Go (Build Tools):**
|
||||
|
||||
@@ -247,31 +206,6 @@ to be used for different players or game situations within the same server proce
|
||||
- Map validation tests ensure game content integrity
|
||||
- Use `GameSettings_test_utils.cpp` and `ShardokEngineBasedTestData.cpp` for C++ test helpers
|
||||
|
||||
### Scala Testing Patterns
|
||||
|
||||
**Use `inside()` instead of `asInstanceOf` for type matching in tests:**
|
||||
|
||||
Never use `asInstanceOf` in tests. Instead, use ScalaTest's `inside()` pattern for safe type matching:
|
||||
|
||||
```scala
|
||||
// BAD - don't do this
|
||||
val changedHero = result.changedHeroes.head.asInstanceOf[ChangedHeroC]
|
||||
changedHero.heroId shouldBe 19
|
||||
|
||||
// GOOD - use inside() pattern
|
||||
import org.scalatest.Inside.inside
|
||||
|
||||
inside(result.changedHeroes.head) { case changedHero: ChangedHeroC =>
|
||||
changedHero.heroId shouldBe 19
|
||||
changedHero.vigorChange shouldBe StatDelta(17.2)
|
||||
}
|
||||
```
|
||||
|
||||
The `inside()` pattern:
|
||||
- Provides better error messages when the type doesn't match
|
||||
- Is idiomatic ScalaTest
|
||||
- Works with pattern matching for more complex assertions
|
||||
|
||||
## Performance Testing
|
||||
|
||||
When making performance-related changes to the AI or engine:
|
||||
@@ -310,40 +244,12 @@ done
|
||||
- **Always test performance changes** - what seems like an optimization may sometimes have unexpected overhead or
|
||||
behavior changes.
|
||||
|
||||
## Troubleshooting Scala Build Errors
|
||||
|
||||
### MissingType Errors
|
||||
|
||||
When you see errors like:
|
||||
```
|
||||
dotty.tools.dotc.core.MissingType: Cannot resolve reference to type net.eagle0.eagle.internal.game_state.type.GameState
|
||||
```
|
||||
|
||||
**This is NOT a Scala compiler crash.** This is a missing dependency in BUILD.bazel.
|
||||
|
||||
**How to fix:**
|
||||
1. Identify the missing type from the error message (e.g., `game_state.GameState`)
|
||||
2. Find the Bazel target that provides this type (e.g., `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`)
|
||||
3. Add it to the `deps` of the failing target
|
||||
4. If the type appears in a public method signature, also add it to `exports` so downstream targets can see it
|
||||
|
||||
**Common pattern:** When adding a method to a class that takes or returns a proto type, the proto dependency often needs to be added to both `deps` AND `exports`.
|
||||
|
||||
### Bazel Clean
|
||||
|
||||
**NEVER run `bazel clean` without asking first.** It rarely fixes actual issues and wastes significant rebuild time. The issues that seem like they need `bazel clean` are usually:
|
||||
- Missing imports in Scala code
|
||||
- Missing dependencies in BUILD.bazel
|
||||
- Missing exports for types used in public signatures
|
||||
|
||||
## Game Content
|
||||
|
||||
**Maps:** `.e0mj` files in `/src/main/resources/net/eagle0/shardok/maps/`
|
||||
**Configuration:** Game parameters in `/src/main/resources/net/eagle0/eagle/game_parameters.json`
|
||||
**Data Files:** TSV format for battalions, heroes, and other game data
|
||||
|
||||
**NEVER modify hero names.** Do not change names in `heroes.tsv`, `generated_heroes`, or any other hero data files. The names are carefully chosen and are not to be altered.
|
||||
|
||||
## Deployment
|
||||
|
||||
- Bazel handles multi-language builds and dependencies
|
||||
|
||||
+28
-321
@@ -1,205 +1,100 @@
|
||||
module(name = "net_eagle0")
|
||||
|
||||
# Version constants
|
||||
SCALA_VERSION = "3.7.4"
|
||||
SCALA_VERSION = "3.7.2"
|
||||
|
||||
NETTY_VERSION = "4.1.127.Final"
|
||||
NETTY_VERSION = "4.1.110.Final"
|
||||
|
||||
SCALAPB_VERSION = "1.0.0-alpha.1"
|
||||
|
||||
AWS_SDK_VERSION = "2.41.18"
|
||||
AWS_SDK_VERSION = "2.28.1"
|
||||
|
||||
#
|
||||
# Core Build Tools
|
||||
#
|
||||
|
||||
bazel_dep(name = "bazel_skylib", version = "1.9.0")
|
||||
bazel_dep(name = "platforms", version = "1.0.0")
|
||||
bazel_dep(name = "rules_pkg", version = "1.2.0")
|
||||
bazel_dep(name = "bazel_skylib", version = "1.8.1")
|
||||
bazel_dep(name = "rules_pkg", version = "1.1.0")
|
||||
|
||||
#
|
||||
# Language Support - Scala
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_scala", version = "7.2.1")
|
||||
bazel_dep(name = "rules_scala", version = "7.1.1")
|
||||
|
||||
scala_config = use_extension(
|
||||
"@rules_scala//scala/extensions:config.bzl",
|
||||
"scala_config",
|
||||
)
|
||||
|
||||
scala_config.settings(scala_version = SCALA_VERSION)
|
||||
|
||||
scala_deps = use_extension(
|
||||
"@rules_scala//scala/extensions:deps.bzl",
|
||||
"scala_deps",
|
||||
)
|
||||
|
||||
scala_deps.scala()
|
||||
|
||||
scala_deps.scalatest()
|
||||
|
||||
scala_deps.scala_proto()
|
||||
|
||||
#
|
||||
# Language Support - C++
|
||||
#
|
||||
|
||||
bazel_dep(name = "toolchains_llvm", version = "1.6.0")
|
||||
bazel_dep(name = "toolchains_llvm", version = "1.4.0")
|
||||
|
||||
llvm = use_extension("@toolchains_llvm//toolchain/extensions:llvm.bzl", "llvm")
|
||||
|
||||
# Native toolchain (macOS -> macOS, Linux -> Linux)
|
||||
llvm.toolchain(
|
||||
name = "llvm_toolchain",
|
||||
llvm_version = "20.1.4",
|
||||
llvm_version = "20.1.2",
|
||||
)
|
||||
|
||||
# Cross-compilation toolchain (macOS -> Linux x86_64)
|
||||
# Uses the same LLVM distribution but with a Linux sysroot
|
||||
llvm.toolchain(
|
||||
name = "llvm_toolchain_linux",
|
||||
llvm_version = "20.1.4",
|
||||
)
|
||||
|
||||
# Linux x86_64 sysroot for cross-compilation
|
||||
llvm.sysroot(
|
||||
name = "llvm_toolchain_linux",
|
||||
label = "@linux_sysroot//sysroot",
|
||||
targets = ["linux-x86_64"],
|
||||
)
|
||||
|
||||
# Cross-compilation toolchain (macOS -> Linux ARM64)
|
||||
llvm.toolchain(
|
||||
name = "llvm_toolchain_linux_arm64",
|
||||
llvm_version = "20.1.4",
|
||||
)
|
||||
|
||||
# Linux ARM64 sysroot for cross-compilation
|
||||
llvm.sysroot(
|
||||
name = "llvm_toolchain_linux_arm64",
|
||||
label = "@linux_sysroot_arm64//sysroot",
|
||||
targets = ["linux-aarch64"],
|
||||
)
|
||||
|
||||
use_repo(llvm, "llvm_toolchain", "llvm_toolchain_linux", "llvm_toolchain_linux_arm64")
|
||||
|
||||
# Download the Linux sysroots (Ubuntu 24.04 Noble for C++23 support)
|
||||
# Built by: .github/workflows/build_sysroot.yml
|
||||
# To rebuild: Run the "Build Linux Sysroot" workflow with a new version, then update sha256 and URL
|
||||
sysroot = use_repo_rule("@toolchains_llvm//toolchain:sysroot.bzl", "sysroot")
|
||||
|
||||
# x86_64 sysroot
|
||||
sysroot(
|
||||
name = "linux_sysroot",
|
||||
sha256 = "a06475004fe8003ae7ccb4fe1d5511feb9b27cce4a8826eb1dfd686ed83f3dba",
|
||||
urls = ["https://eagle0-sysroot.sfo3.digitaloceanspaces.com/v3/ubuntu_noble_amd64_sysroot.tar.xz"],
|
||||
)
|
||||
|
||||
# ARM64 sysroot
|
||||
sysroot(
|
||||
name = "linux_sysroot_arm64",
|
||||
sha256 = "87469137737e09bc73855007dab835477eb10a7b3ce3f725f93f64e25747f3f9",
|
||||
urls = ["https://eagle0-sysroot.sfo3.digitaloceanspaces.com/v4/ubuntu_noble_arm64_sysroot.tar.xz"],
|
||||
)
|
||||
use_repo(llvm, "llvm_toolchain")
|
||||
|
||||
#
|
||||
# Language Support - Go
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_go", version = "0.59.0", repo_name = "io_bazel_rules_go")
|
||||
bazel_dep(name = "gazelle", version = "0.47.0", repo_name = "bazel_gazelle")
|
||||
bazel_dep(name = "rules_go", repo_name = "io_bazel_rules_go", version = "0.56.1")
|
||||
bazel_dep(name = "gazelle", repo_name = "bazel_gazelle", version = "0.45.0")
|
||||
|
||||
go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk")
|
||||
|
||||
go_sdk.download(version = "1.23.3")
|
||||
use_repo(go_sdk, "go_default_sdk")
|
||||
|
||||
go_deps = use_extension("@bazel_gazelle//:extensions.bzl", "go_deps")
|
||||
|
||||
go_deps.from_file(go_mod = "//:go.mod")
|
||||
|
||||
use_repo(
|
||||
go_deps,
|
||||
"com_github_aws_aws_sdk_go_v2",
|
||||
"com_github_aws_aws_sdk_go_v2_config",
|
||||
"com_github_aws_aws_sdk_go_v2_credentials",
|
||||
"com_github_aws_aws_sdk_go_v2_service_s3",
|
||||
"com_github_golang_jwt_jwt_v5",
|
||||
"com_github_google_uuid",
|
||||
"com_github_webview_webview_go",
|
||||
"org_golang_google_grpc",
|
||||
"org_golang_google_protobuf",
|
||||
"org_golang_x_sys",
|
||||
)
|
||||
|
||||
#
|
||||
# Language Support - Rust
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_rust", version = "0.68.1")
|
||||
|
||||
rust = use_extension("@rules_rust//rust:extensions.bzl", "rust")
|
||||
rust.toolchain(edition = "2021")
|
||||
use_repo(rust, "rust_toolchains")
|
||||
|
||||
register_toolchains("@rust_toolchains//:all")
|
||||
|
||||
crate = use_extension("@rules_rust//crate_universe:extension.bzl", "crate")
|
||||
crate.from_cargo(
|
||||
name = "map_generator_crates",
|
||||
cargo_lockfile = "//src/main/rust/net/eagle0/eagle/map_generator:Cargo.lock",
|
||||
manifests = ["//src/main/rust/net/eagle0/eagle/map_generator:Cargo.toml"],
|
||||
)
|
||||
use_repo(crate, "map_generator_crates")
|
||||
|
||||
#
|
||||
# Platform Support - Apple/iOS
|
||||
#
|
||||
|
||||
bazel_dep(name = "apple_support", version = "1.24.1", repo_name = "build_bazel_apple_support")
|
||||
bazel_dep(name = "rules_apple", version = "4.3.3", repo_name = "build_bazel_rules_apple")
|
||||
|
||||
# Register Apple CC toolchain for Objective-C compilation
|
||||
apple_cc_configure = use_extension(
|
||||
"@build_bazel_apple_support//crosstool:setup.bzl",
|
||||
"apple_cc_configure_extension",
|
||||
)
|
||||
use_repo(apple_cc_configure, "local_config_apple_cc", "local_config_apple_cc_toolchains")
|
||||
bazel_dep(name = "apple_support", repo_name = "build_bazel_apple_support", version = "1.21.1")
|
||||
bazel_dep(name = "rules_apple", repo_name = "build_bazel_rules_apple", version = "3.16.1")
|
||||
bazel_dep(name = "rules_swift", repo_name = "build_bazel_rules_swift", version = "2.3.1")
|
||||
|
||||
#
|
||||
# Protocol Buffers & RPC
|
||||
#
|
||||
|
||||
bazel_dep(name = "protobuf", version = "33.5", repo_name = "com_google_protobuf")
|
||||
|
||||
# Use pre-built protoc binaries instead of compiling from source.
|
||||
# See: https://protobuf.dev/reference/cpp/cpp-generated/#invocation
|
||||
prebuilt_protoc = use_extension("@com_google_protobuf//bazel/private:prebuilt_protoc_extension.bzl", "protoc")
|
||||
use_repo(
|
||||
prebuilt_protoc,
|
||||
"prebuilt_protoc.linux_aarch_64",
|
||||
"prebuilt_protoc.linux_ppcle_64",
|
||||
"prebuilt_protoc.linux_s390_64",
|
||||
"prebuilt_protoc.linux_x86_32",
|
||||
"prebuilt_protoc.linux_x86_64",
|
||||
"prebuilt_protoc.osx_aarch_64",
|
||||
"prebuilt_protoc.osx_x86_64",
|
||||
"prebuilt_protoc.win32",
|
||||
"prebuilt_protoc.win64",
|
||||
)
|
||||
|
||||
bazel_dep(name = "rules_proto", version = "7.1.0")
|
||||
bazel_dep(name = "rules_cc", version = "0.2.14")
|
||||
bazel_dep(name = "grpc", version = "1.78.0")
|
||||
|
||||
# Patch grpc 1.78.0 to fix rules_python incompatibility: grpc requests Python
|
||||
# 3.14.0b2 toolchain but rules_python >= 1.6.0 removed it in favor of 3.14.
|
||||
# Upstream fix: https://github.com/grpc/grpc/commit/459279b
|
||||
# TODO: Remove this override once a fixed grpc version is published to BCR.
|
||||
single_version_override(
|
||||
module_name = "grpc",
|
||||
patch_strip = 1,
|
||||
patches = ["//third_party/patches:grpc_fix_python_version.patch"],
|
||||
version = "1.78.0",
|
||||
)
|
||||
|
||||
bazel_dep(name = "grpc-java", version = "1.78.0")
|
||||
bazel_dep(name = "rules_proto_grpc_csharp", version = "5.8.0")
|
||||
bazel_dep(name = "flatbuffers", version = "25.12.19")
|
||||
bazel_dep(name = "protobuf", repo_name = "com_google_protobuf", version = "29.2")
|
||||
bazel_dep(name = "grpc", version = "1.71.0")
|
||||
bazel_dep(name = "grpc-java", version = "1.71.0")
|
||||
bazel_dep(name = "flatbuffers", version = "25.2.10")
|
||||
|
||||
#
|
||||
# Testing
|
||||
@@ -207,64 +102,14 @@ bazel_dep(name = "flatbuffers", version = "25.12.19")
|
||||
|
||||
bazel_dep(name = "googletest", version = "1.17.0")
|
||||
|
||||
#
|
||||
# Container Images (OCI)
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_oci", version = "2.2.7")
|
||||
bazel_dep(name = "aspect_bazel_lib", version = "2.22.5")
|
||||
|
||||
oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
|
||||
|
||||
# Base image for Eagle (Java 25 JRE - smaller runtime, no dev tools needed)
|
||||
# Digest pinned for Bazel repository cache hit; update with:
|
||||
# TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/eclipse-temurin:pull" | python3 -c "import json,sys; print(json.load(sys.stdin)['token'])")
|
||||
# curl -s -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json" "https://registry-1.docker.io/v2/library/eclipse-temurin/manifests/25-jre" | python3 -c "import json,sys; m=json.load(sys.stdin); [print(p['digest']) for p in m.get('manifests',[]) if p['platform']['architecture']=='amd64']"
|
||||
oci.pull(
|
||||
name = "eclipse_temurin_25_jre",
|
||||
digest = "sha256:98236ffdfb61cff3fc4f8e40e5b80460a5a4c35a0f56a5b4182a926844a7db49",
|
||||
image = "docker.io/library/eclipse-temurin",
|
||||
platforms = ["linux/amd64"],
|
||||
tag = "25-jre",
|
||||
)
|
||||
|
||||
# Base image for JFR sidecar (Java 25 JDK - includes jcmd for JFR dumps)
|
||||
oci.pull(
|
||||
name = "eclipse_temurin_25_jdk",
|
||||
digest = "sha256:bc2d562355813350f47bc472b9721b0b84761866671ad95d9a9f1151fa69da6e",
|
||||
image = "docker.io/library/eclipse-temurin",
|
||||
platforms = ["linux/amd64"],
|
||||
tag = "25-jdk",
|
||||
)
|
||||
|
||||
# Base image for Shardok (Ubuntu 24.04 for C++ runtime)
|
||||
oci.pull(
|
||||
name = "ubuntu_24_04",
|
||||
image = "docker.io/library/ubuntu",
|
||||
platforms = [
|
||||
"linux/amd64",
|
||||
"linux/arm64/v8",
|
||||
],
|
||||
tag = "24.04",
|
||||
)
|
||||
|
||||
# Base image for Admin Server (Alpine for lightweight Go binary)
|
||||
oci.pull(
|
||||
name = "alpine_linux",
|
||||
image = "docker.io/library/alpine",
|
||||
platforms = ["linux/amd64"],
|
||||
tag = "3.21",
|
||||
)
|
||||
use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_25_jdk", "eclipse_temurin_25_jdk_linux_amd64", "eclipse_temurin_25_jre", "eclipse_temurin_25_jre_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64", "ubuntu_24_04_linux_arm64_v8")
|
||||
|
||||
#
|
||||
# Java/Scala Dependencies
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_java", version = "9.3.0")
|
||||
bazel_dep(name = "rules_jvm_external", version = "6.10")
|
||||
bazel_dep(name = "rules_jvm_external", version = "6.3")
|
||||
|
||||
maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")
|
||||
|
||||
maven.install(
|
||||
artifacts = [
|
||||
# Netty
|
||||
@@ -315,19 +160,6 @@ maven.install(
|
||||
# Other
|
||||
"org.reactivestreams:reactive-streams:1.0.4",
|
||||
"javax.xml.bind:jaxb-api:2.3.1",
|
||||
|
||||
# OkHttp (for SSE with read timeout support, OAuth HTTP calls)
|
||||
"com.squareup.okhttp3:okhttp:4.12.0",
|
||||
"com.squareup.okhttp3:okhttp-sse:4.12.0",
|
||||
|
||||
# JWT (for OAuth token handling)
|
||||
"com.nimbusds:nimbus-jose-jwt:9.37.3",
|
||||
|
||||
# Error tracking
|
||||
"io.sentry:sentry:8.31.0",
|
||||
|
||||
# SQLite for client text storage
|
||||
"org.xerial:sqlite-jdbc:3.46.1.0",
|
||||
],
|
||||
duplicate_version_warning = "error",
|
||||
fail_if_repin_required = True,
|
||||
@@ -337,26 +169,6 @@ maven.install(
|
||||
],
|
||||
)
|
||||
|
||||
# Force specific versions for dependencies with conflicts between grpc-java and protobuf
|
||||
maven.artifact(
|
||||
artifact = "gson",
|
||||
force_version = True,
|
||||
group = "com.google.code.gson",
|
||||
version = "2.11.0",
|
||||
)
|
||||
maven.artifact(
|
||||
artifact = "error_prone_annotations",
|
||||
force_version = True,
|
||||
group = "com.google.errorprone",
|
||||
version = "2.30.0",
|
||||
)
|
||||
maven.artifact(
|
||||
artifact = "guava",
|
||||
force_version = True,
|
||||
group = "com.google.guava",
|
||||
version = "33.3.1-android",
|
||||
)
|
||||
|
||||
use_repo(maven, "maven", "unpinned_maven")
|
||||
|
||||
#
|
||||
@@ -364,7 +176,6 @@ use_repo(maven, "maven", "unpinned_maven")
|
||||
#
|
||||
|
||||
http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
|
||||
http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file")
|
||||
|
||||
# GTL (for parallel_hashmap)
|
||||
GTL_VERSION = "1.2.0"
|
||||
@@ -373,14 +184,7 @@ GTL_SHA = "1969c45dd76eac0dd87e9e2b65cffe358617f4fe1bcd203f72f427742537913a"
|
||||
|
||||
http_archive(
|
||||
name = "gtl",
|
||||
build_file_content = """
|
||||
cc_library(
|
||||
name = "gtl",
|
||||
hdrs = glob(["include/gtl/*.hpp"]),
|
||||
includes = ["include"],
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
""",
|
||||
build_file = "@//external:BUILD.gtl",
|
||||
sha256 = GTL_SHA,
|
||||
strip_prefix = "gtl-%s" % GTL_VERSION,
|
||||
url = "https://github.com/greg7mdp/gtl/archive/refs/tags/v%s.zip" % GTL_VERSION,
|
||||
@@ -400,96 +204,6 @@ http_archive(
|
||||
],
|
||||
)
|
||||
|
||||
# Sparkle framework for macOS auto-updates
|
||||
SPARKLE_VERSION = "2.6.4"
|
||||
|
||||
http_archive(
|
||||
name = "sparkle",
|
||||
build_file = "//src/main/objc/net/eagle0/clients/unity/sparkle/external:BUILD.sparkle",
|
||||
sha256 = "50612a06038abc931f16011d7903b8326a362c1074dabccb718404ce8e585f0b",
|
||||
strip_prefix = "",
|
||||
url = "https://github.com/sparkle-project/Sparkle/releases/download/%s/Sparkle-%s.tar.xz" % (SPARKLE_VERSION, SPARKLE_VERSION),
|
||||
)
|
||||
|
||||
# Busybox static binary for Docker health checks (provides nc, wget, etc.)
|
||||
# Primary: DigitalOcean Spaces (public, reliable)
|
||||
# Fallback: busybox.net (can be unreliable/slow)
|
||||
http_file(
|
||||
name = "busybox_x86_64",
|
||||
sha256 = "6e123e7f3202a8c1e9b1f94d8941580a25135382b99e8d3e34fb858bba311348",
|
||||
urls = [
|
||||
"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/busybox/busybox-1.35.0-x86_64-linux-musl",
|
||||
"https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox",
|
||||
],
|
||||
downloaded_file_path = "busybox",
|
||||
executable = True,
|
||||
)
|
||||
|
||||
http_file(
|
||||
name = "busybox_aarch64",
|
||||
sha256 = "141adb1b625a6f44c4b114f76b4387b4ea4f7ab802b88eb40e0d2f6adcccb1c3",
|
||||
urls = [
|
||||
# TODO: Upload aarch64 binary to GitHub release when needed
|
||||
"https://busybox.net/downloads/binaries/1.35.0-aarch64-linux-musl/busybox",
|
||||
],
|
||||
downloaded_file_path = "busybox",
|
||||
executable = True,
|
||||
)
|
||||
|
||||
# LLVM MinGW toolchain for Windows cross-compilation from macOS
|
||||
# This provides a complete toolchain for building Windows executables including
|
||||
# the MinGW-w64 libraries needed for CGO cross-compilation
|
||||
LLVM_MINGW_VERSION = "20250305"
|
||||
|
||||
http_archive(
|
||||
name = "llvm_mingw",
|
||||
build_file_content = """
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
filegroup(
|
||||
name = "all_files",
|
||||
srcs = glob(["**/*"]),
|
||||
)
|
||||
|
||||
filegroup(
|
||||
name = "compiler_files",
|
||||
srcs = glob([
|
||||
"bin/x86_64-w64-mingw32-*",
|
||||
"bin/clang*",
|
||||
"bin/llvm-*",
|
||||
"bin/lld*",
|
||||
]),
|
||||
)
|
||||
|
||||
filegroup(
|
||||
name = "windows_x86_64_sysroot",
|
||||
srcs = glob([
|
||||
"x86_64-w64-mingw32/**/*",
|
||||
"generic-w64-mingw32/include/**/*",
|
||||
]),
|
||||
)
|
||||
|
||||
filegroup(
|
||||
name = "linker_files",
|
||||
srcs = glob([
|
||||
"bin/x86_64-w64-mingw32-*",
|
||||
"bin/lld*",
|
||||
"bin/ld.lld*",
|
||||
"lib/**/*",
|
||||
"x86_64-w64-mingw32/lib/**/*",
|
||||
]),
|
||||
)
|
||||
|
||||
exports_files([
|
||||
"bin/x86_64-w64-mingw32-clang",
|
||||
"bin/x86_64-w64-mingw32-clang++",
|
||||
])
|
||||
""",
|
||||
sha256 = "32c24fc62fc8b9f8a900bf2c730b78b36767688f816f9d21e97a168289ff44e0",
|
||||
strip_prefix = "llvm-mingw-%s-ucrt-macos-14.4.1-universal" % LLVM_MINGW_VERSION,
|
||||
urls = ["https://github.com/fathonix/llvm-mingw-arm64ec-macos/releases/download/%s/llvm-mingw-%s-ucrt-macos-14.4.1-universal.tar.xz" % (LLVM_MINGW_VERSION, LLVM_MINGW_VERSION)],
|
||||
)
|
||||
|
||||
#
|
||||
# Toolchain Registration
|
||||
#
|
||||
@@ -499,15 +213,8 @@ register_toolchains(
|
||||
"@rules_scala//testing:scalatest_toolchain",
|
||||
)
|
||||
|
||||
# Apple CC toolchain for Objective-C compilation (SparklePlugin).
|
||||
# Registered before LLVM so it has higher priority; Apple constraint matching
|
||||
# ensures it's only used for Apple-platform targets (objc_library, macos_bundle).
|
||||
register_toolchains("@local_config_apple_cc_toolchains//:all")
|
||||
|
||||
# Set dev_dependency so we can turn this off for swift MacOS builds
|
||||
register_toolchains(
|
||||
"@llvm_toolchain//:all",
|
||||
"@llvm_toolchain_linux//:all",
|
||||
"@llvm_toolchain_linux_arm64//:all",
|
||||
dev_dependency = True,
|
||||
)
|
||||
|
||||
Generated
+3831
-3290
File diff suppressed because one or more lines are too long
@@ -0,0 +1,205 @@
|
||||
# Scala 3 Modernization Guide
|
||||
|
||||
## Overview
|
||||
This document outlines opportunities to modernize the Eagle0 codebase to use Scala 3 best practices and features. The migration to Scala 3 is complete, but the code still uses many Scala 2 patterns that can be improved.
|
||||
|
||||
## Modernization Opportunities
|
||||
|
||||
### 1. **Convert Sealed Traits to Enums** 🎯 HIGH IMPACT
|
||||
**Benefits**: Better performance, more concise syntax, improved exhaustiveness checking
|
||||
|
||||
**Current pattern** (`ExternalTextGenerationCaller.scala:23-31`):
|
||||
```scala
|
||||
sealed trait ExternalTextGenerationError extends Error {
|
||||
def message: String
|
||||
}
|
||||
case class ExternalTextGenerationRateLimitError(code: Int, message: String)
|
||||
extends ExternalTextGenerationError
|
||||
case class ExternalTextGenerationHttpError(code: Int, message: String)
|
||||
extends ExternalTextGenerationError
|
||||
case class ExternalTextGenerationTimeoutError(message: String)
|
||||
extends ExternalTextGenerationError
|
||||
```
|
||||
|
||||
**Scala 3 improvement**:
|
||||
```scala
|
||||
enum ExternalTextGenerationError extends Error:
|
||||
case RateLimit(code: Int, message: String)
|
||||
case Http(code: Int, message: String)
|
||||
case Timeout(message: String)
|
||||
|
||||
def message: String = this match
|
||||
case RateLimit(_, msg) => msg
|
||||
case Http(_, msg) => msg
|
||||
case Timeout(msg) => msg
|
||||
```
|
||||
|
||||
**Files to check**:
|
||||
- `/src/main/scala/net/eagle0/common/llm_integration/ExternalTextGenerationCaller.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request/GeneratedTextRequestT.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/model/state/quest/concrete/QuestC.scala`
|
||||
|
||||
### 2. **Convert Implicit Classes to Extension Methods** 🎯 HIGH IMPACT
|
||||
**Benefits**: Modern syntax, better IDE support, cleaner imports
|
||||
|
||||
**Current pattern** (`MoreSeq.scala:23-26`):
|
||||
```scala
|
||||
implicit def SeqCollect[A, Repr[_]](coll: Repr[A])(implicit
|
||||
itr: IsIterable[Repr[A]]
|
||||
): SeqCollect[A, Repr, itr.type] =
|
||||
new SeqCollect[A, Repr, itr.type](coll, itr)
|
||||
```
|
||||
|
||||
**Scala 3 improvement**:
|
||||
```scala
|
||||
extension [A, Repr[_]](coll: Repr[A])(using itr: IsIterable[Repr[A]])
|
||||
def flatCollect[B](pf: PartialFunction[itr.A, Option[B]])(using Factory[B, Repr[B]]): Repr[B] =
|
||||
Factory[B, Repr[B]].fromSpecific(itr(coll).collect(pf).flatten)
|
||||
|
||||
def flatCollectFirst[B](pf: PartialFunction[itr.A, Option[B]]): Option[B] =
|
||||
itr(coll).collect(pf).flatten.headOption
|
||||
```
|
||||
|
||||
**Files to check**:
|
||||
- `/src/main/scala/net/eagle0/common/MoreSeq.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/CommandChooser.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/service/new_game_creation/NewGameCreation.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultProtoApplierImpl.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/service/new_game_creation/StartGameActionResultUtils.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/model/state/date/Date.scala`
|
||||
|
||||
### 3. **Convert Implicit Parameters to Using Clauses** 🎯 MEDIUM IMPACT
|
||||
**Benefits**: Cleaner syntax, better tooling support, clearer intent
|
||||
|
||||
**Current pattern**:
|
||||
```scala
|
||||
def method[T](value: T)(implicit ec: ExecutionContext): Future[T]
|
||||
def process[A](items: Seq[A])(implicit ord: Ordering[A]): Seq[A]
|
||||
```
|
||||
|
||||
**Scala 3 improvement**:
|
||||
```scala
|
||||
def method[T](value: T)(using ExecutionContext): Future[T]
|
||||
def process[A](items: Seq[A])(using Ordering[A]): Seq[A]
|
||||
```
|
||||
|
||||
**Files to check**:
|
||||
- `/src/main/scala/net/eagle0/common/MoreSeq.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/library/util/hero_name_fetcher/HeroNameFetcher.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/library/util/ShardokMapInfo.scala`
|
||||
- `/src/main/scala/net/eagle0/common/llm_integration/OpenAIChatCompletionsServiceImpl.scala`
|
||||
- `/src/main/scala/net/eagle0/common/llm_integration/ClaudeServiceImpl.scala`
|
||||
|
||||
### 4. **Opaque Types for Type Safety** 🎯 MEDIUM IMPACT
|
||||
**Benefits**: Zero runtime cost, compile-time type safety, prevents mixing up similar types
|
||||
|
||||
**Pattern to look for**: Type aliases that represent distinct concepts
|
||||
```scala
|
||||
// Instead of: type UserId = String, type GameId = String
|
||||
opaque type UserId = String
|
||||
object UserId:
|
||||
def apply(s: String): UserId = s
|
||||
extension (id: UserId)
|
||||
def value: String = id
|
||||
def isValid: Boolean = id.nonEmpty && id.length > 3
|
||||
|
||||
opaque type GameId = Long
|
||||
object GameId:
|
||||
def apply(l: Long): GameId = l
|
||||
extension (id: GameId) def value: Long = id
|
||||
```
|
||||
|
||||
**Candidates**: Look for simple type aliases and ID types throughout the codebase.
|
||||
|
||||
### 5. **Inline Methods for Performance** 🎯 LOW IMPACT
|
||||
**Benefits**: Compile-time optimization, better performance for hot paths
|
||||
|
||||
**Pattern**: Mark small, frequently-called methods as `inline`
|
||||
```scala
|
||||
inline def isValidId(id: String): Boolean =
|
||||
id.nonEmpty && id.length > 3
|
||||
|
||||
inline def calculateScore(base: Int, multiplier: Double): Double =
|
||||
base * multiplier
|
||||
```
|
||||
|
||||
**Candidates**: Small utility methods in performance-critical paths (AI calculations, game state updates).
|
||||
|
||||
### 6. **Union Types Instead of Complex Hierarchies** 🎯 LOW IMPACT
|
||||
**Benefits**: Simpler type definitions for either/or scenarios
|
||||
|
||||
**Pattern**: Simple sealed traits with only case classes
|
||||
```scala
|
||||
// Instead of:
|
||||
sealed trait Result
|
||||
case class Success(value: String) extends Result
|
||||
case class Error(message: String) extends Result
|
||||
|
||||
// Consider:
|
||||
type Result = Success | Error
|
||||
case class Success(value: String)
|
||||
case class Error(message: String)
|
||||
```
|
||||
|
||||
### 7. **Context Functions for Cleaner APIs** 🎯 LOW IMPACT
|
||||
**Benefits**: Cleaner API design, implicit context passing
|
||||
|
||||
**Pattern**: Replace implicit function parameters
|
||||
```scala
|
||||
// Old
|
||||
type Handler = GameState => Unit
|
||||
def withGameState(gs: GameState)(handler: Handler): Unit = handler(gs)
|
||||
|
||||
// New
|
||||
type Handler = GameState ?=> Unit
|
||||
def withGameState(gs: GameState)(handler: Handler): Unit =
|
||||
given GameState = gs
|
||||
handler
|
||||
```
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
### Phase 1: Quick Wins (High Impact, Low Risk)
|
||||
1. **Convert Extension Methods** in `MoreSeq.scala` - immediate readability improvement
|
||||
2. **Update Using Clauses** - simple find/replace operation
|
||||
3. **Convert Simple Sealed Traits to Enums** - start with error types
|
||||
|
||||
### Phase 2: Type Safety Improvements
|
||||
4. **Add Opaque Types** for IDs and measurements - improves type safety
|
||||
5. **Inline Performance-Critical Methods** - measure before/after impact
|
||||
|
||||
### Phase 3: Advanced Features (Lower Priority)
|
||||
6. **Union Types** where appropriate - only for simple either/or cases
|
||||
7. **Context Functions** for complex API improvements
|
||||
|
||||
## Implementation Guidelines
|
||||
|
||||
### Style Consistency
|
||||
- **Keep curly braces**: Continue using Scala 2 style `{}` instead of indentation-based syntax
|
||||
- **Gradual adoption**: Modernize files as they're touched for other reasons
|
||||
- **Test thoroughly**: Each modernization should include verification that behavior is unchanged
|
||||
|
||||
### Performance Considerations
|
||||
- **Measure enum performance**: Verify that enum conversion actually improves performance in hot paths
|
||||
- **Benchmark inline methods**: Use profiling to confirm performance gains
|
||||
- **Consider compilation time**: Some features may increase compile time
|
||||
|
||||
### Migration Strategy
|
||||
- **File-by-file approach**: Complete modernization of one file at a time
|
||||
- **Separate PRs**: Each modernization type should be its own PR for easier review
|
||||
- **Documentation**: Update this document as patterns are modernized
|
||||
|
||||
## Success Criteria
|
||||
- [ ] All extension methods converted from implicit classes
|
||||
- [ ] All implicit parameters converted to using clauses
|
||||
- [ ] Key sealed traits converted to enums where appropriate
|
||||
- [ ] Opaque types introduced for important ID types
|
||||
- [ ] Performance-critical methods marked as inline (with benchmarks)
|
||||
- [ ] No regression in functionality or performance
|
||||
- [ ] Code remains readable and maintainable
|
||||
|
||||
## Notes
|
||||
- Focus on high-impact, low-risk improvements first
|
||||
- Each change should be driven by clear benefits (performance, readability, type safety)
|
||||
- Maintain backward compatibility where possible
|
||||
- Document any breaking changes clearly
|
||||
-376
@@ -1,376 +0,0 @@
|
||||
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load", "oci_push")
|
||||
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
|
||||
|
||||
#
|
||||
# Deployment artifacts (tools needed on the host, not in containers)
|
||||
#
|
||||
|
||||
pkg_tar(
|
||||
name = "warmup_tar",
|
||||
srcs = ["//src/main/go/net/eagle0/warmup:warmup_linux_amd64"],
|
||||
package_dir = "bin",
|
||||
remap_paths = {
|
||||
"/warmup_linux_amd64": "/warmup",
|
||||
},
|
||||
)
|
||||
|
||||
#
|
||||
# Shared utilities layer (busybox for nc, wget, etc.)
|
||||
#
|
||||
|
||||
pkg_tar(
|
||||
name = "busybox_layer",
|
||||
srcs = ["@busybox_x86_64//file"],
|
||||
package_dir = "/usr/local/bin",
|
||||
remap_paths = {
|
||||
"file/busybox": "busybox",
|
||||
},
|
||||
symlinks = {
|
||||
"/usr/local/bin/nc": "busybox",
|
||||
},
|
||||
)
|
||||
|
||||
pkg_tar(
|
||||
name = "busybox_layer_arm64",
|
||||
srcs = ["@busybox_aarch64//file"],
|
||||
package_dir = "/usr/local/bin",
|
||||
remap_paths = {
|
||||
"file/busybox": "busybox",
|
||||
},
|
||||
symlinks = {
|
||||
"/usr/local/bin/nc": "busybox",
|
||||
},
|
||||
)
|
||||
|
||||
#
|
||||
# Eagle Server Docker Image
|
||||
#
|
||||
# Build: bazel build //ci:eagle_server_image
|
||||
# Load: bazel run //ci:eagle_server_load
|
||||
# Push: bazel run //ci:eagle_server_push
|
||||
#
|
||||
|
||||
# Package the deploy JAR
|
||||
pkg_tar(
|
||||
name = "eagle_server_jar_layer",
|
||||
srcs = ["//src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar"],
|
||||
package_dir = "/app",
|
||||
)
|
||||
|
||||
# Package the game resources needed at runtime
|
||||
pkg_tar(
|
||||
name = "eagle_resources_layer",
|
||||
srcs = [
|
||||
"//src/main/resources/net/eagle0/eagle:beasts",
|
||||
"//src/main/resources/net/eagle0/eagle:game_parameters",
|
||||
"//src/main/resources/net/eagle0/eagle:headshots",
|
||||
"//src/main/resources/net/eagle0/eagle:heroes",
|
||||
"//src/main/resources/net/eagle0/eagle:province_map",
|
||||
"//src/main/resources/net/eagle0/eagle:settings",
|
||||
],
|
||||
package_dir = "/app/resources",
|
||||
)
|
||||
|
||||
oci_image(
|
||||
name = "eagle_server_image",
|
||||
base = "@eclipse_temurin_25_jre_linux_amd64",
|
||||
entrypoint = [
|
||||
"java",
|
||||
"-Xmx2g",
|
||||
"-XX:+UseG1GC",
|
||||
# JFR profiling support
|
||||
"-XX:+UnlockDiagnosticVMOptions",
|
||||
"-XX:+DebugNonSafepoints", # Required for JFR to see through inlined methods
|
||||
"-XX:FlightRecorderOptions=stackdepth=256",
|
||||
"-jar",
|
||||
"/app/eagle_server_deploy.jar",
|
||||
],
|
||||
env = {
|
||||
"JAVA_OPTS": "-Xmx2g -XX:+UseG1GC",
|
||||
},
|
||||
exposed_ports = ["40032/tcp"],
|
||||
tars = [
|
||||
":busybox_layer",
|
||||
":eagle_server_jar_layer",
|
||||
":eagle_resources_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
)
|
||||
|
||||
# Load into Docker locally: bazel run //ci:eagle_server_load
|
||||
oci_load(
|
||||
name = "eagle_server_load",
|
||||
image = ":eagle_server_image",
|
||||
repo_tags = ["eagle0/eagle-server:latest"],
|
||||
)
|
||||
|
||||
# Push to DigitalOcean Container Registry
|
||||
# Note: No remote_tags here - DigitalOcean converts OCI to Docker format,
|
||||
# changing the digest and breaking oci_push's tag-by-digest logic.
|
||||
# Tagging is handled in the CI workflow using crane copy/tag.
|
||||
oci_push(
|
||||
name = "eagle_server_push",
|
||||
image = ":eagle_server_image",
|
||||
repository = "registry.digitalocean.com/eagle0/eagle-server",
|
||||
)
|
||||
|
||||
#
|
||||
# Shardok Server Docker Image
|
||||
#
|
||||
# Build: bazel build //ci:shardok_server_image
|
||||
# Load: bazel run //ci:shardok_server_load
|
||||
# Push: bazel run //ci:shardok_server_push
|
||||
#
|
||||
|
||||
# Package the Shardok binary
|
||||
pkg_tar(
|
||||
name = "shardok_binary_layer",
|
||||
srcs = ["//src/main/cpp/net/eagle0/shardok:shardok-server"],
|
||||
package_dir = "/app",
|
||||
)
|
||||
|
||||
# Package the Shardok resources (battalion types, settings)
|
||||
pkg_tar(
|
||||
name = "shardok_resources_layer",
|
||||
srcs = [
|
||||
"//src/main/resources/net/eagle0/shardok:battalion_types",
|
||||
"//src/main/resources/net/eagle0/shardok:settings",
|
||||
],
|
||||
package_dir = "/app/resources",
|
||||
)
|
||||
|
||||
# Package the converted maps
|
||||
pkg_tar(
|
||||
name = "shardok_maps_layer",
|
||||
srcs = ["//src/main/resources/net/eagle0/shardok/maps"],
|
||||
package_dir = "/app/resources/maps",
|
||||
)
|
||||
|
||||
oci_image(
|
||||
name = "shardok_server_image",
|
||||
base = "@ubuntu_24_04_linux_amd64",
|
||||
entrypoint = ["/app/shardok-server"],
|
||||
exposed_ports = [
|
||||
"40042/tcp",
|
||||
"40052/tcp",
|
||||
],
|
||||
tars = [
|
||||
":busybox_layer",
|
||||
":shardok_binary_layer",
|
||||
":shardok_resources_layer",
|
||||
":shardok_maps_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
)
|
||||
|
||||
# Load into Docker locally: bazel run //ci:shardok_server_load
|
||||
oci_load(
|
||||
name = "shardok_server_load",
|
||||
image = ":shardok_server_image",
|
||||
repo_tags = ["eagle0/shardok-server:latest"],
|
||||
)
|
||||
|
||||
# Push to DigitalOcean Container Registry
|
||||
# Note: No remote_tags here - DigitalOcean converts OCI to Docker format,
|
||||
# changing the digest and breaking oci_push's tag-by-digest logic.
|
||||
# Tagging is handled in the CI workflow using crane copy/tag.
|
||||
oci_push(
|
||||
name = "shardok_server_push",
|
||||
image = ":shardok_server_image",
|
||||
repository = "registry.digitalocean.com/eagle0/shardok-server",
|
||||
)
|
||||
|
||||
#
|
||||
# Shardok Server ARM64 Docker Image (for Hetzner on-demand compute)
|
||||
#
|
||||
# Build: bazel build //ci:shardok_server_image_arm64 --platforms=//:linux_arm64 --extra_toolchains=@llvm_toolchain_linux_arm64//:all
|
||||
# Load: bazel run //ci:shardok_server_load_arm64
|
||||
# Push: bazel run //ci:shardok_server_push_arm64
|
||||
#
|
||||
|
||||
# Package the Shardok binary (ARM64 version - must be built with --platforms=//:linux_arm64)
|
||||
pkg_tar(
|
||||
name = "shardok_binary_layer_arm64",
|
||||
srcs = ["//src/main/cpp/net/eagle0/shardok:shardok-server"],
|
||||
package_dir = "/app",
|
||||
)
|
||||
|
||||
oci_image(
|
||||
name = "shardok_server_image_arm64",
|
||||
base = "@ubuntu_24_04_linux_arm64_v8",
|
||||
entrypoint = ["/app/shardok-server"],
|
||||
exposed_ports = [
|
||||
"40042/tcp",
|
||||
"40052/tcp",
|
||||
],
|
||||
tars = [
|
||||
# Note: busybox_layer_arm64 omitted - busybox.net has SSL issues
|
||||
# Health checks can use the shardok-server binary itself or be added later
|
||||
":shardok_binary_layer_arm64",
|
||||
":shardok_resources_layer",
|
||||
":shardok_maps_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
)
|
||||
|
||||
# Load into Docker locally (ARM64): bazel run //ci:shardok_server_load_arm64
|
||||
oci_load(
|
||||
name = "shardok_server_load_arm64",
|
||||
image = ":shardok_server_image_arm64",
|
||||
repo_tags = ["eagle0/shardok-server:latest-arm64"],
|
||||
)
|
||||
|
||||
# Push to DigitalOcean Container Registry (for Hetzner deployment)
|
||||
# Uses same repository as x86 but with arm64- tag prefix
|
||||
oci_push(
|
||||
name = "shardok_server_push_arm64",
|
||||
image = ":shardok_server_image_arm64",
|
||||
repository = "registry.digitalocean.com/eagle0/shardok-server",
|
||||
)
|
||||
|
||||
#
|
||||
# Admin Server Docker Image (Go)
|
||||
#
|
||||
# Build: bazel build //ci:admin_server_image
|
||||
# Load: bazel run //ci:admin_server_load
|
||||
# Push: bazel run //ci:admin_server_push
|
||||
#
|
||||
|
||||
# Package the Go admin binary (explicit Linux x86_64 target)
|
||||
pkg_tar(
|
||||
name = "admin_binary_layer",
|
||||
srcs = ["//src/main/go/net/eagle0/admin_server:admin_server_linux_amd64"],
|
||||
package_dir = "/app",
|
||||
)
|
||||
|
||||
# Package the .e0mj map files for the map editor
|
||||
pkg_tar(
|
||||
name = "admin_maps_layer",
|
||||
srcs = ["//src/main/resources/net/eagle0/shardok/maps:maps_json"],
|
||||
package_dir = "/app/maps",
|
||||
)
|
||||
|
||||
oci_image(
|
||||
name = "admin_server_image",
|
||||
base = "@alpine_linux_linux_amd64",
|
||||
entrypoint = ["/app/admin_server_linux_amd64"],
|
||||
exposed_ports = ["8080/tcp"],
|
||||
tars = [
|
||||
":busybox_layer",
|
||||
":admin_binary_layer",
|
||||
":admin_maps_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
)
|
||||
|
||||
# Load into Docker locally: bazel run //ci:admin_server_load
|
||||
oci_load(
|
||||
name = "admin_server_load",
|
||||
image = ":admin_server_image",
|
||||
repo_tags = ["eagle0/admin-server:latest"],
|
||||
)
|
||||
|
||||
# Push to DigitalOcean Container Registry
|
||||
oci_push(
|
||||
name = "admin_server_push",
|
||||
image = ":admin_server_image",
|
||||
repository = "registry.digitalocean.com/eagle0/admin-server",
|
||||
)
|
||||
|
||||
#
|
||||
# JFR Sidecar Docker Image (Go + JDK for jcmd)
|
||||
#
|
||||
# This sidecar runs with shared PID namespace to access the Eagle JVM.
|
||||
# Build: bazel build //ci:jfr_sidecar_image
|
||||
# Load: bazel run //ci:jfr_sidecar_load
|
||||
# Push: bazel run //ci:jfr_sidecar_push
|
||||
#
|
||||
|
||||
# Package the Go JFR server binary
|
||||
pkg_tar(
|
||||
name = "jfr_sidecar_binary_layer",
|
||||
srcs = ["//src/main/go/net/eagle0/jfr_server:jfr_server_linux_amd64"],
|
||||
package_dir = "/app",
|
||||
)
|
||||
|
||||
oci_image(
|
||||
name = "jfr_sidecar_image",
|
||||
# Use JDK base image - we need jcmd to dump JFR recordings
|
||||
base = "@eclipse_temurin_25_jdk_linux_amd64",
|
||||
entrypoint = ["/app/jfr_server_linux_amd64"],
|
||||
exposed_ports = ["8081/tcp"],
|
||||
tars = [
|
||||
":jfr_sidecar_binary_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
)
|
||||
|
||||
# Load into Docker locally: bazel run //ci:jfr_sidecar_load
|
||||
oci_load(
|
||||
name = "jfr_sidecar_load",
|
||||
image = ":jfr_sidecar_image",
|
||||
repo_tags = ["eagle0/jfr-sidecar:latest"],
|
||||
)
|
||||
|
||||
# Push to DigitalOcean Container Registry
|
||||
oci_push(
|
||||
name = "jfr_sidecar_push",
|
||||
image = ":jfr_sidecar_image",
|
||||
repository = "registry.digitalocean.com/eagle0/jfr-sidecar",
|
||||
)
|
||||
|
||||
#
|
||||
# Auth Server Docker Image (Go)
|
||||
#
|
||||
# This is the external OAuth service that handles OAuth flows and JWT creation.
|
||||
# Build: bazel build //ci:auth_server_image
|
||||
# Load: bazel run //ci:auth_server_load
|
||||
# Push: bazel run //ci:auth_server_push
|
||||
#
|
||||
|
||||
# Package the Go auth binary (explicit Linux x86_64 target)
|
||||
pkg_tar(
|
||||
name = "auth_binary_layer",
|
||||
srcs = [
|
||||
"//src/main/go/net/eagle0/authcli:authcli_linux_amd64",
|
||||
"//src/main/go/net/eagle0/authservice:authservice_linux_amd64",
|
||||
],
|
||||
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",
|
||||
entrypoint = ["/app/authservice_linux_amd64"],
|
||||
exposed_ports = [
|
||||
"40033/tcp", # gRPC
|
||||
"8080/tcp", # HTTP OAuth callback
|
||||
],
|
||||
tars = [
|
||||
":busybox_layer",
|
||||
":auth_binary_layer",
|
||||
":auth_attributions_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
)
|
||||
|
||||
# Load into Docker locally: bazel run //ci:auth_server_load
|
||||
oci_load(
|
||||
name = "auth_server_load",
|
||||
image = ":auth_server_image",
|
||||
repo_tags = ["eagle0/auth-server:latest"],
|
||||
)
|
||||
|
||||
# Push to DigitalOcean Container Registry
|
||||
oci_push(
|
||||
name = "auth_server_push",
|
||||
image = ":auth_server_image",
|
||||
repository = "registry.digitalocean.com/eagle0/auth-server",
|
||||
)
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
/bin/mkdir -p win_output
|
||||
/usr/bin/dotnet publish src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller.sln -o win_output
|
||||
SHA=`sha256sum /tmp/EagleInstaller.exe | awk '{print $1 }'`
|
||||
ZIP_FILE="updater__$SHA.zip"
|
||||
|
||||
/usr/bin/zip win_output/$ZIP_FILE win_output/EagleInstaller.exe
|
||||
rm win_output/EagleInstaller.exe
|
||||
rm win_output/EagleInstaller.pdb
|
||||
|
||||
DATE=`date +"%Y-%m-%d %T"`
|
||||
|
||||
cat > win_output/updater.html <<-EOF
|
||||
<html>
|
||||
<head>
|
||||
<title>Download Eagle Updater</title>
|
||||
</head>
|
||||
<body>
|
||||
<a href="http://eagle0.net/assets/$ZIP_FILE">$ZIP_FILE</a> (updated $DATE)
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
|
||||
SSH_KEY_FILE=$1
|
||||
SSH_USER_NAME=$2
|
||||
|
||||
/usr/bin/rsync -r --copy-links -e "/usr/bin/ssh -i $SSH_KEY_FILE -p 9022" win_output/ $SSH_USER_NAME@eagle0.net:/www/assets/
|
||||
@@ -1,155 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Archive and export iOS app for App Store / TestFlight
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
# Ensure xcodebuild uses Xcode.app, not Command Line Tools
|
||||
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||
|
||||
XCODE_PROJECT_PATH=${1:?Usage: archive_ios.sh <xcode_project_path> <output_path> <team_id> <profile_uuid>}
|
||||
OUTPUT_PATH=${2:?Missing output path}
|
||||
TEAM_ID=${3:?Missing team ID}
|
||||
PROFILE_UUID=${4:?Missing provisioning profile UUID}
|
||||
|
||||
ARCHIVE_PATH="$OUTPUT_PATH/eagle0.xcarchive"
|
||||
EXPORT_PATH="$OUTPUT_PATH"
|
||||
|
||||
echo "Archiving iOS app..."
|
||||
echo " Xcode project: $XCODE_PROJECT_PATH"
|
||||
echo " Archive path: $ARCHIVE_PATH"
|
||||
echo " Team ID: $TEAM_ID"
|
||||
|
||||
mkdir -p "$OUTPUT_PATH"
|
||||
|
||||
# Find the .xcodeproj file
|
||||
XCODEPROJ=$(find "$XCODE_PROJECT_PATH" -name "*.xcodeproj" -maxdepth 1 | head -1)
|
||||
if [ -z "$XCODEPROJ" ]; then
|
||||
echo "Error: No .xcodeproj found in $XCODE_PROJECT_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Found Xcode project: $XCODEPROJ"
|
||||
|
||||
# Ensure export compliance key is set in Info.plist
|
||||
# This bypasses the "Missing Compliance" prompt in App Store Connect.
|
||||
# Set here rather than relying solely on Unity's DeepLinkPostProcessor,
|
||||
# which depends on UNITY_IOS being defined at script compilation time.
|
||||
INFO_PLIST="$XCODE_PROJECT_PATH/Info.plist"
|
||||
if [ -f "$INFO_PLIST" ]; then
|
||||
/usr/libexec/PlistBuddy -c "Set :ITSAppUsesNonExemptEncryption false" "$INFO_PLIST" 2>/dev/null \
|
||||
|| /usr/libexec/PlistBuddy -c "Add :ITSAppUsesNonExemptEncryption bool false" "$INFO_PLIST"
|
||||
echo "Set ITSAppUsesNonExemptEncryption=false in Info.plist"
|
||||
|
||||
# Remove UIApplicationSceneManifest if present.
|
||||
# Unity 6 generates this key, which opts the app into the iOS scene-based
|
||||
# lifecycle. This breaks Application.deepLinkActivated because iOS routes
|
||||
# deep link URLs through UISceneDelegate instead of UIApplicationDelegate,
|
||||
# and Unity doesn't implement the scene delegate path.
|
||||
# See: https://issuetracker.unity3d.com/issues/in-135632
|
||||
if /usr/libexec/PlistBuddy -c "Print :UIApplicationSceneManifest" "$INFO_PLIST" 2>/dev/null; then
|
||||
echo "Found UIApplicationSceneManifest in Info.plist — removing to fix deep link handling"
|
||||
/usr/libexec/PlistBuddy -c "Delete :UIApplicationSceneManifest" "$INFO_PLIST"
|
||||
else
|
||||
echo "No UIApplicationSceneManifest in Info.plist (deep links should work)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 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..."
|
||||
|
||||
# Restore keychain search list before export.
|
||||
# xcodebuild archive can reset the user keychain search list during long
|
||||
# builds, dropping the temporary CI keychain that holds the signing cert.
|
||||
# Re-add it here so xcodebuild -exportArchive can find the certificate.
|
||||
if [ -n "${KEYCHAIN_NAME:-}" ]; then
|
||||
echo "Restoring keychain search list (adding $KEYCHAIN_NAME)"
|
||||
security list-keychains -d user -s "$KEYCHAIN_NAME" login.keychain
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
|
||||
fi
|
||||
|
||||
# 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 with App Store Connect API key authentication.
|
||||
# Without the API key, xcodebuild falls back to the Xcode-Token in the
|
||||
# keychain, which expires periodically and breaks headless CI builds.
|
||||
EXPORT_AUTH_ARGS=()
|
||||
if [ -n "${APP_STORE_CONNECT_API_KEY_PATH:-}" ] && [ -n "${APP_STORE_CONNECT_API_KEY_ID:-}" ] && [ -n "${APP_STORE_CONNECT_API_ISSUER_ID:-}" ]; then
|
||||
echo "Using App Store Connect API key for export authentication"
|
||||
EXPORT_AUTH_ARGS=(
|
||||
-authenticationKeyPath "$APP_STORE_CONNECT_API_KEY_PATH"
|
||||
-authenticationKeyID "$APP_STORE_CONNECT_API_KEY_ID"
|
||||
-authenticationKeyIssuerID "$APP_STORE_CONNECT_API_ISSUER_ID"
|
||||
)
|
||||
fi
|
||||
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath "$ARCHIVE_PATH" \
|
||||
-exportPath "$EXPORT_PATH" \
|
||||
-exportOptionsPlist "$EXPORT_OPTIONS_PLIST" \
|
||||
"${EXPORT_AUTH_ARGS[@]}"
|
||||
|
||||
# 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"
|
||||
|
||||
# Clean up DerivedData for Unity-iPhone builds.
|
||||
# Each build creates a new ~3.6GB folder (Unity-iPhone-<random>) that
|
||||
# accumulates and wastes disk space on the build runner.
|
||||
echo "Cleaning up Unity-iPhone DerivedData..."
|
||||
find ~/Library/Developer/Xcode/DerivedData -maxdepth 1 -name 'Unity-iPhone-*' -type d -exec rm -rf {} + 2>/dev/null || true
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
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: //')
|
||||
|
||||
WORKSPACE=$(pwd)
|
||||
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
|
||||
BUILD_DIR=$1
|
||||
LOG_PATH=$2
|
||||
|
||||
echo "Building Mac in $BUILD_DIR"
|
||||
|
||||
echo "Cleaning up $BUILD_DIR"
|
||||
/bin/rm -rf "$BUILD_DIR"
|
||||
/bin/mkdir -p "$BUILD_DIR"
|
||||
|
||||
# Use custom build script that builds Addressables before the player
|
||||
# Capture exit code to show log on failure
|
||||
set +e
|
||||
${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
|
||||
-nographics \
|
||||
-batchmode \
|
||||
-quit \
|
||||
-executeMethod BuildScript.BuildMacPlayer \
|
||||
-buildPath "$BUILD_DIR/eagle0.app" \
|
||||
-logFile "$LOG_PATH" \
|
||||
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
|
||||
UNITY_EXIT_CODE=$?
|
||||
set -e
|
||||
|
||||
if [ $UNITY_EXIT_CODE -ne 0 ]; then
|
||||
echo ""
|
||||
echo "Unity build failed with exit code $UNITY_EXIT_CODE"
|
||||
echo "=== Unity Editor Log (last 200 lines) ==="
|
||||
tail -200 "$LOG_PATH" || echo "Could not read log file at $LOG_PATH"
|
||||
echo "=== End of Unity Editor Log ==="
|
||||
exit $UNITY_EXIT_CODE
|
||||
fi
|
||||
@@ -2,18 +2,20 @@
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
# Use runner-specific build directory if EAGLE0_BUILD_DIR is set, otherwise default
|
||||
BUILD_BASE="${EAGLE0_BUILD_DIR:-/tmp/eagle0}"
|
||||
LOG_PATH=""
|
||||
|
||||
COMMIT=$(/usr/bin/git rev-parse --short HEAD)
|
||||
|
||||
/bin/echo "build protos"
|
||||
./scripts/build_protos.sh
|
||||
|
||||
/bin/echo "build plugins"
|
||||
./scripts/build_windows_plugin.sh
|
||||
|
||||
git log -3
|
||||
|
||||
/bin/echo "build Windows"
|
||||
LOG_PATH="${BUILD_BASE}/editor_win.log"
|
||||
LOG_PATH="/tmp/eagle0/editor_win.log"
|
||||
BUILD_DIR=$1
|
||||
|
||||
./ci/github_actions/build_windows.sh $BUILD_DIR $LOG_PATH
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Build iOS Unity player (generates Xcode project)
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
# Read Unity version from project file
|
||||
UNITY_VERSION=$(grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //')
|
||||
|
||||
# Use runner-specific build directory if EAGLE0_BUILD_DIR is set, otherwise default
|
||||
BUILD_BASE="${EAGLE0_BUILD_DIR:-/tmp/eagle0}"
|
||||
|
||||
WORKSPACE=$(pwd)
|
||||
BUILD_PATH=${1:-"${BUILD_BASE}/eagle0iOS"}
|
||||
LOG_PATH="${BUILD_BASE}/editor_ios.log"
|
||||
|
||||
# Generate unique build number from git commit count
|
||||
# This ensures each build has a unique number for TestFlight
|
||||
BUILD_NUMBER=$(git rev-list --count HEAD)
|
||||
echo "Build number (git commit count): $BUILD_NUMBER"
|
||||
|
||||
echo "Building protos"
|
||||
./scripts/build_protos.sh
|
||||
|
||||
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
|
||||
|
||||
echo "Building iOS Unity player to: $BUILD_PATH"
|
||||
|
||||
mkdir -p "$(dirname "$LOG_PATH")"
|
||||
mkdir -p "$BUILD_PATH"
|
||||
|
||||
# Build iOS player - this generates an Xcode project
|
||||
# Capture exit code to show log on failure
|
||||
set +e
|
||||
${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
|
||||
-nographics \
|
||||
-batchmode \
|
||||
-quit \
|
||||
-executeMethod BuildScript.BuildiOSPlayer \
|
||||
-buildPath "$BUILD_PATH" \
|
||||
-buildNumber "$BUILD_NUMBER" \
|
||||
-logFile "$LOG_PATH" \
|
||||
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
|
||||
UNITY_EXIT_CODE=$?
|
||||
set -e
|
||||
|
||||
if [ $UNITY_EXIT_CODE -ne 0 ]; then
|
||||
echo ""
|
||||
echo "Unity build failed with exit code $UNITY_EXIT_CODE"
|
||||
echo "=== Unity Editor Log (last 200 lines) ==="
|
||||
tail -200 "$LOG_PATH" || echo "Could not read log file at $LOG_PATH"
|
||||
echo "=== End of Unity Editor Log ==="
|
||||
exit $UNITY_EXIT_CODE
|
||||
fi
|
||||
|
||||
echo "iOS Unity build complete"
|
||||
echo "Xcode project generated at: $BUILD_PATH"
|
||||
ls -la "$BUILD_PATH"
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
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"
|
||||
./scripts/build_protos.sh
|
||||
|
||||
/bin/echo "build Sparkle plugin"
|
||||
./scripts/build_sparkle_plugin.sh
|
||||
|
||||
git log -3
|
||||
|
||||
/bin/echo "build Mac"
|
||||
LOG_PATH="${BUILD_BASE}/editor_mac.log"
|
||||
BUILD_DIR=$1
|
||||
|
||||
./ci/github_actions/build_mac.sh "$BUILD_DIR" "$LOG_PATH"
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
# Read Unity version from project file
|
||||
UNITY_VERSION=$(grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //')
|
||||
. ./ci/unity_version.sh
|
||||
|
||||
WORKSPACE=`pwd`
|
||||
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
|
||||
@@ -16,25 +15,10 @@ echo "Cleaning up $1"
|
||||
/bin/rm -rf $1
|
||||
/bin/mkdir -p $1
|
||||
|
||||
# Use custom build script that builds Addressables before the player
|
||||
# Capture exit code to show log on failure
|
||||
set +e
|
||||
${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
|
||||
-nographics \
|
||||
-batchmode \
|
||||
-quit \
|
||||
-executeMethod BuildScript.BuildWindowsPlayer \
|
||||
-buildPath "$BUILD_DIR/eagle0.exe" \
|
||||
-buildWindows64Player $BUILD_DIR/eagle0.exe \
|
||||
-logFile $LOG_PATH \
|
||||
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
|
||||
UNITY_EXIT_CODE=$?
|
||||
set -e
|
||||
|
||||
if [ $UNITY_EXIT_CODE -ne 0 ]; then
|
||||
echo ""
|
||||
echo "Unity build failed with exit code $UNITY_EXIT_CODE"
|
||||
echo "=== Unity Editor Log (last 200 lines) ==="
|
||||
tail -200 "$LOG_PATH" || echo "Could not read log file at $LOG_PATH"
|
||||
echo "=== End of Unity Editor Log ==="
|
||||
exit $UNITY_EXIT_CODE
|
||||
fi
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Ensures the required Unity version is installed via Unity Hub.
|
||||
# Usage: ./ensure_unity_installed.sh [PLATFORM]
|
||||
#
|
||||
# PLATFORM can be: mac, windows, ios, or all (default: all)
|
||||
#
|
||||
# This script:
|
||||
# 1. Reads the required version from ProjectSettings/ProjectVersion.txt
|
||||
# 2. Checks if it's already installed
|
||||
# 3. If not, attempts to install it via Unity Hub CLI with appropriate modules
|
||||
#
|
||||
# Note: Unity Hub CLI installation may require:
|
||||
# - Unity Hub to be installed
|
||||
# - User to be logged in to Unity Hub (for some versions)
|
||||
# - Appropriate licenses
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Read Unity version directly from the project file (maintained by Unity itself)
|
||||
PROJECT_VERSION_FILE="src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
|
||||
UNITY_VERSION=$(grep "m_EditorVersion:" "$PROJECT_VERSION_FILE" | head -1 | sed 's/m_EditorVersion: //')
|
||||
|
||||
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
|
||||
UNITY_HUB_CLI="/Applications/Unity Hub.app/Contents/MacOS/Unity Hub"
|
||||
LOCK_FILE="/tmp/unity_install.lock"
|
||||
LOCK_TIMEOUT=1800 # 30 minutes max wait for another installation
|
||||
|
||||
PLATFORM="${1:-all}"
|
||||
|
||||
echo "Required Unity version: ${UNITY_VERSION}"
|
||||
echo "Platform: ${PLATFORM}"
|
||||
|
||||
# Check if Unity is installed and has required modules
|
||||
check_modules_installed() {
|
||||
local unity_path="${UNITY_INSTALL_PATH}/${UNITY_VERSION}"
|
||||
|
||||
if [ ! -d "$unity_path" ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
case "${PLATFORM}" in
|
||||
ios)
|
||||
# iOS module installs to PlaybackEngines/iOSSupport
|
||||
if [ ! -d "${unity_path}/PlaybackEngines/iOSSupport" ]; then
|
||||
echo "✗ iOS module not installed for Unity ${UNITY_VERSION}"
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
mac)
|
||||
# Mac IL2CPP module
|
||||
if [ ! -d "${unity_path}/PlaybackEngines/MacStandaloneSupport/Variations/macos_development_il2cpp" ] && \
|
||||
[ ! -d "${unity_path}/PlaybackEngines/MacStandaloneSupport" ]; then
|
||||
echo "✗ Mac IL2CPP module not installed for Unity ${UNITY_VERSION}"
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
windows)
|
||||
# Windows mono module
|
||||
if [ ! -d "${unity_path}/PlaybackEngines/WindowsStandaloneSupport" ]; then
|
||||
echo "✗ Windows module not installed for Unity ${UNITY_VERSION}"
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
all)
|
||||
# Check all required modules
|
||||
local missing=0
|
||||
if [ ! -d "${unity_path}/PlaybackEngines/iOSSupport" ]; then
|
||||
echo "✗ iOS module missing"
|
||||
missing=1
|
||||
fi
|
||||
if [ ! -d "${unity_path}/PlaybackEngines/MacStandaloneSupport" ]; then
|
||||
echo "✗ Mac module missing"
|
||||
missing=1
|
||||
fi
|
||||
if [ ! -d "${unity_path}/PlaybackEngines/WindowsStandaloneSupport" ]; then
|
||||
echo "✗ Windows module missing"
|
||||
missing=1
|
||||
fi
|
||||
if [ $missing -eq 1 ]; then
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Check if already installed with required modules
|
||||
if check_modules_installed; then
|
||||
echo "✓ Unity ${UNITY_VERSION} is already installed with ${PLATFORM} support"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -d "${UNITY_INSTALL_PATH}/${UNITY_VERSION}" ]; then
|
||||
echo "✗ Unity ${UNITY_VERSION} not found at ${UNITY_INSTALL_PATH}/${UNITY_VERSION}"
|
||||
fi
|
||||
|
||||
# Check if Unity Hub CLI is available
|
||||
if [ ! -f "${UNITY_HUB_CLI}" ]; then
|
||||
echo ""
|
||||
echo "Unity Hub CLI not found at ${UNITY_HUB_CLI}"
|
||||
echo ""
|
||||
echo "To install Unity ${UNITY_VERSION} manually:"
|
||||
echo " 1. Open Unity Hub"
|
||||
echo " 2. Go to Installs -> Install Editor"
|
||||
echo " 3. Select version ${UNITY_VERSION}"
|
||||
echo " 4. Add modules based on platform: mac-il2cpp, windows-mono, ios"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Acquire lock to prevent concurrent installations
|
||||
acquire_lock() {
|
||||
local waited=0
|
||||
while ! mkdir "${LOCK_FILE}" 2>/dev/null; do
|
||||
if [ $waited -ge $LOCK_TIMEOUT ]; then
|
||||
echo "ERROR: Timed out waiting for Unity installation lock after ${LOCK_TIMEOUT}s"
|
||||
echo "Another installation may be stuck. Remove ${LOCK_FILE} manually if needed."
|
||||
exit 1
|
||||
fi
|
||||
echo "Another Unity installation is in progress, waiting... (${waited}s)"
|
||||
sleep 10
|
||||
waited=$((waited + 10))
|
||||
done
|
||||
# Store PID for debugging
|
||||
echo $$ > "${LOCK_FILE}/pid"
|
||||
trap release_lock EXIT
|
||||
}
|
||||
|
||||
release_lock() {
|
||||
rm -rf "${LOCK_FILE}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Re-check after acquiring lock (another process may have installed it)
|
||||
acquire_lock
|
||||
|
||||
if check_modules_installed; then
|
||||
echo "✓ Unity ${UNITY_VERSION} with ${PLATFORM} support was installed while waiting for lock"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Determine modules needed for this platform
|
||||
get_modules_for_platform() {
|
||||
case "${PLATFORM}" in
|
||||
mac)
|
||||
echo "mac-il2cpp"
|
||||
;;
|
||||
windows)
|
||||
echo "windows-mono"
|
||||
;;
|
||||
ios)
|
||||
echo "ios"
|
||||
;;
|
||||
all)
|
||||
echo "mac-il2cpp windows-mono ios"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown platform: ${PLATFORM}" >&2
|
||||
echo "Valid platforms: mac, windows, ios, all" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
MODULES=$(get_modules_for_platform)
|
||||
|
||||
# Check if editor is already installed (just missing modules)
|
||||
if [ -d "${UNITY_INSTALL_PATH}/${UNITY_VERSION}" ]; then
|
||||
echo "Unity ${UNITY_VERSION} is installed but missing modules. Adding modules..."
|
||||
echo ""
|
||||
|
||||
# Use install-modules to add modules to existing installation
|
||||
INSTALL_CMD=("${UNITY_HUB_CLI}" -- --headless install-modules --version "${UNITY_VERSION}")
|
||||
for mod in $MODULES; do
|
||||
INSTALL_CMD+=(--module "$mod")
|
||||
done
|
||||
else
|
||||
echo "Attempting to install Unity ${UNITY_VERSION} via Unity Hub CLI..."
|
||||
echo ""
|
||||
|
||||
# Use install to install editor with modules
|
||||
INSTALL_CMD=("${UNITY_HUB_CLI}" -- --headless install --version "${UNITY_VERSION}")
|
||||
for mod in $MODULES; do
|
||||
INSTALL_CMD+=(--module "$mod")
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Running: ${INSTALL_CMD[*]}"
|
||||
echo ""
|
||||
|
||||
# Capture output to check for "already installed" messages
|
||||
OUTPUT=$("${INSTALL_CMD[@]}" 2>&1) || true
|
||||
EXIT_CODE=$?
|
||||
echo "$OUTPUT"
|
||||
|
||||
# Check if modules are already installed (Unity Hub returns error but modules are present)
|
||||
if echo "$OUTPUT" | grep -q "already installed\|No modules found to install"; then
|
||||
echo ""
|
||||
echo "✓ Modules already installed"
|
||||
elif [ $EXIT_CODE -eq 0 ]; then
|
||||
echo ""
|
||||
echo "Unity Hub CLI command completed"
|
||||
else
|
||||
echo ""
|
||||
echo "Unity Hub CLI command failed (exit code: ${EXIT_CODE})"
|
||||
echo ""
|
||||
echo "This may happen if:"
|
||||
echo " - The Unity version is not available for download"
|
||||
echo " - You need to log in to Unity Hub first"
|
||||
echo " - Unity Hub requires a GUI interaction"
|
||||
echo ""
|
||||
echo "To install manually:"
|
||||
echo " 1. Open Unity Hub"
|
||||
echo " 2. Go to Installs -> Install Editor"
|
||||
echo " 3. Select version ${UNITY_VERSION}"
|
||||
echo " 4. Add modules: ${PLATFORM}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify installation
|
||||
echo ""
|
||||
if [ -d "${UNITY_INSTALL_PATH}/${UNITY_VERSION}" ]; then
|
||||
echo "✓ Verified: Unity ${UNITY_VERSION} is now installed"
|
||||
exit 0
|
||||
else
|
||||
echo "✗ Unity ${UNITY_VERSION} installation could not be verified"
|
||||
echo " Expected path: ${UNITY_INSTALL_PATH}/${UNITY_VERSION}"
|
||||
echo ""
|
||||
echo "The installation may still be in progress, or may require manual intervention."
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,35 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Fetch LFS files from Gitea mirror with retry.
|
||||
#
|
||||
# Gitea's mirror-sync API is async — the webhook fires on push but the
|
||||
# actual sync may still be in progress when CI starts. Retry with backoff
|
||||
# to bridge the gap.
|
||||
#
|
||||
# Usage:
|
||||
# ./ci/github_actions/fetch_lfs.sh # full pull
|
||||
# ./ci/github_actions/fetch_lfs.sh --include="path/to/*" # selective pull
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MAX_ATTEMPTS=5
|
||||
RETRY_DELAY=15
|
||||
|
||||
git lfs install
|
||||
|
||||
echo "LFS objects before pull:"
|
||||
git lfs ls-files | wc -l
|
||||
|
||||
for i in $(seq 1 $MAX_ATTEMPTS); do
|
||||
if git lfs pull "$@"; then
|
||||
echo "LFS objects after pull:"
|
||||
git lfs ls-files | wc -l
|
||||
exit 0
|
||||
fi
|
||||
if [ "$i" -lt "$MAX_ATTEMPTS" ]; then
|
||||
echo "LFS pull attempt $i/$MAX_ATTEMPTS failed, retrying in ${RETRY_DELAY}s..."
|
||||
sleep $RETRY_DELAY
|
||||
fi
|
||||
done
|
||||
|
||||
echo "LFS pull failed after $MAX_ATTEMPTS attempts"
|
||||
exit 1
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
/bin/echo "persist Library/"
|
||||
/usr/bin/rsync -rtlDvq src/main/csharp/net/eagle0/clients/unity/eagle0/Library/ /tmp/eagle0/Library/
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
/bin/echo "restore Library/"
|
||||
/bin/mkdir -p /tmp/eagle0/Library
|
||||
/usr/bin/rsync -rtlDvq /tmp/eagle0/Library/ src/main/csharp/net/eagle0/clients/unity/eagle0/Library/
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Upload Addressables bundles to DigitalOcean Spaces
|
||||
# Usage: ./upload_addressables.sh <build_target>
|
||||
# Example: ./upload_addressables.sh StandaloneOSX
|
||||
#
|
||||
# Required environment variables:
|
||||
# ACCESS_KEY_ID - DigitalOcean Spaces access key (same as other deploys)
|
||||
# SECRET_KEY - DigitalOcean Spaces secret key (same as other deploys)
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
BUILD_TARGET=$1
|
||||
WORKSPACE=$(pwd)
|
||||
UNITY_PROJECT="$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
|
||||
SERVER_DATA="$UNITY_PROJECT/ServerData/$BUILD_TARGET"
|
||||
|
||||
# DigitalOcean Spaces configuration (same region as other eagle0 buckets)
|
||||
DO_ENDPOINT="https://sfo3.digitaloceanspaces.com"
|
||||
DO_BUCKET="eagle0-assets"
|
||||
|
||||
if [ ! -d "$SERVER_DATA" ]; then
|
||||
echo "No Addressables bundles found at $SERVER_DATA"
|
||||
echo "Skipping upload (this is expected if Addressables are bundled locally)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Uploading Addressables bundles from $SERVER_DATA"
|
||||
echo "Target: s3://$DO_BUCKET/addressables/$BUILD_TARGET/"
|
||||
|
||||
# Configure AWS CLI for DigitalOcean Spaces
|
||||
export AWS_ACCESS_KEY_ID="$ACCESS_KEY_ID"
|
||||
export AWS_SECRET_ACCESS_KEY="$SECRET_KEY"
|
||||
|
||||
# Sync bundles to Spaces
|
||||
# --delete removes files in destination that don't exist in source
|
||||
# --acl public-read makes files publicly accessible
|
||||
aws s3 sync "$SERVER_DATA" "s3://$DO_BUCKET/addressables/$BUILD_TARGET/" \
|
||||
--endpoint-url "$DO_ENDPOINT" \
|
||||
--acl public-read \
|
||||
--delete
|
||||
|
||||
echo "Addressables upload complete"
|
||||
echo "Files available at: https://assets.eagle0.net/addressables/$BUILD_TARGET/"
|
||||
@@ -1,102 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Upload xcarchive to TestFlight using App Store Connect API Key
|
||||
# This replaces the deprecated altool which was removed in Xcode 14+
|
||||
#
|
||||
# Uses xcodebuild -exportArchive with destination=upload, which is Apple's
|
||||
# recommended approach for CI/CD pipelines.
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
# Ensure xcodebuild uses Xcode.app, not Command Line Tools
|
||||
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||
|
||||
ARCHIVE_PATH=${1:?Usage: upload_testflight.sh <xcarchive_path> <team_id> <profile_uuid>}
|
||||
TEAM_ID=${2:?Missing team ID}
|
||||
PROFILE_UUID=${3:?Missing provisioning profile UUID}
|
||||
|
||||
if [ ! -d "$ARCHIVE_PATH" ]; then
|
||||
echo "Error: xcarchive not found: $ARCHIVE_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Uploading to TestFlight: $ARCHIVE_PATH"
|
||||
|
||||
# Requires App Store Connect API Key:
|
||||
# - APP_STORE_CONNECT_API_KEY_ID: Key ID from App Store Connect
|
||||
# - APP_STORE_CONNECT_API_ISSUER_ID: Issuer ID from App Store Connect
|
||||
# - APP_STORE_CONNECT_API_KEY_PATH: Path to .p8 private key file
|
||||
#
|
||||
# To create an API key:
|
||||
# 1. Go to https://appstoreconnect.apple.com/access/api
|
||||
# 2. Click the + button to create a new key
|
||||
# 3. Give it a name and Admin or App Manager access
|
||||
# 4. Download the .p8 file (you can only download it once!)
|
||||
# 5. Note the Key ID and Issuer ID shown on the page
|
||||
|
||||
if [ -z "${APP_STORE_CONNECT_API_KEY_ID:-}" ]; then
|
||||
echo "Error: APP_STORE_CONNECT_API_KEY_ID environment variable not set"
|
||||
echo "Create an API key at https://appstoreconnect.apple.com/access/api"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${APP_STORE_CONNECT_API_ISSUER_ID:-}" ]; then
|
||||
echo "Error: APP_STORE_CONNECT_API_ISSUER_ID environment variable not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${APP_STORE_CONNECT_API_KEY_PATH:-}" ]; then
|
||||
echo "Error: APP_STORE_CONNECT_API_KEY_PATH environment variable not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$APP_STORE_CONNECT_API_KEY_PATH" ]; then
|
||||
echo "Error: API key file not found: $APP_STORE_CONNECT_API_KEY_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create export options plist with upload destination
|
||||
EXPORT_OPTIONS_PLIST=$(mktemp)
|
||||
trap "rm -f $EXPORT_OPTIONS_PLIST" EXIT
|
||||
|
||||
cat > "$EXPORT_OPTIONS_PLIST" << EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>destination</key>
|
||||
<string>upload</string>
|
||||
<key>method</key>
|
||||
<string>app-store-connect</string>
|
||||
<key>teamID</key>
|
||||
<string>$TEAM_ID</string>
|
||||
<key>uploadSymbols</key>
|
||||
<true/>
|
||||
<key>signingStyle</key>
|
||||
<string>manual</string>
|
||||
<key>signingCertificate</key>
|
||||
<string>Apple Distribution: Daniel Crosby (UWJ88DX8WQ)</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>net.eagle0.eagle</key>
|
||||
<string>$PROFILE_UUID</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
EOF
|
||||
|
||||
echo "Export options:"
|
||||
cat "$EXPORT_OPTIONS_PLIST"
|
||||
|
||||
echo "Uploading to App Store Connect..."
|
||||
|
||||
# Use xcodebuild to upload with App Store Connect API authentication
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath "$ARCHIVE_PATH" \
|
||||
-exportOptionsPlist "$EXPORT_OPTIONS_PLIST" \
|
||||
-authenticationKeyPath "$APP_STORE_CONNECT_API_KEY_PATH" \
|
||||
-authenticationKeyID "$APP_STORE_CONNECT_API_KEY_ID" \
|
||||
-authenticationKeyIssuerID "$APP_STORE_CONNECT_API_ISSUER_ID"
|
||||
|
||||
echo "Upload complete! Check App Store Connect for processing status."
|
||||
echo "The build should appear in TestFlight within 15-30 minutes after processing."
|
||||
BIN
Binary file not shown.
@@ -1,18 +0,0 @@
|
||||
<?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>
|
||||
<!-- Allow JIT compilation (required for Mono/IL2CPP) -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<!-- Allow unsigned executable memory (required for Unity) -->
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<!-- Disable library validation (required for plugins) -->
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<!-- Allow outgoing network connections -->
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,2 @@
|
||||
|
||||
UNITY_VERSION='6000.2.7f2'
|
||||
@@ -1,309 +0,0 @@
|
||||
# Docker Compose for production deployment
|
||||
#
|
||||
# Local testing:
|
||||
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:auth_server_load
|
||||
# Run: docker compose -f docker-compose.prod.yml up
|
||||
#
|
||||
# Production deployment:
|
||||
# Run: docker compose -f docker-compose.prod.yml up -d
|
||||
#
|
||||
# Note: Shardok runs on Hetzner ARM64 server, deployed via shardok_arm64_build.yml workflow.
|
||||
|
||||
services:
|
||||
# Blue-green deployment: eagle-blue is the primary (production) instance
|
||||
# eagle-green is the staging instance for zero-downtime deployments
|
||||
# See scripts/deploy-blue-green.sh for deployment workflow
|
||||
|
||||
eagle-blue:
|
||||
image: ${EAGLE_IMAGE:-registry.digitalocean.com/eagle0/eagle-server:latest}
|
||||
container_name: eagle-blue
|
||||
command:
|
||||
- "--shardok-interface-remote-address"
|
||||
- "${SHARDOK_ADDRESS}"
|
||||
- "--auth-service-url"
|
||||
- "auth:40033"
|
||||
ports:
|
||||
- "40032:40032"
|
||||
environment:
|
||||
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
|
||||
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
|
||||
GEMINI_API_KEY: "${GEMINI_API_KEY:-}"
|
||||
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
|
||||
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
|
||||
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
|
||||
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
|
||||
# JWT public key for token validation (auth service handles signing)
|
||||
# Reads from /etc/eagle0/keys/public.pem via shared volume
|
||||
# Auth token for Shardok on Hetzner (required)
|
||||
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
|
||||
# Use persistent volume for save data (users, games, etc.)
|
||||
EAGLE_SAVE_DIR: "/app/saves"
|
||||
EAGLE_ARCHIVE_DIR: "/app/archived"
|
||||
SENTRY_DSN: "${SENTRY_DSN:-}"
|
||||
SENTRY_ENVIRONMENT: "production"
|
||||
volumes:
|
||||
- ./saves:/app/saves # Game saves and user database
|
||||
- ./archived:/app/archived # Archived completed games
|
||||
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-blue jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
|
||||
- jvm-tmp:/tmp # Shared with jfr-sidecar for JVM attach socket files
|
||||
- jwt-keys:/etc/eagle0/keys:ro # JWT public key from auth service (read-only)
|
||||
depends_on:
|
||||
- auth
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "100m"
|
||||
max-file: "5"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "nc -z localhost 40032 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
eagle-green:
|
||||
image: ${EAGLE_IMAGE_NEW:-registry.digitalocean.com/eagle0/eagle-server:latest}
|
||||
container_name: eagle-green
|
||||
profiles: ["blue-green"] # Only started during blue-green deployment
|
||||
command:
|
||||
- "--shardok-interface-remote-address"
|
||||
- "${SHARDOK_ADDRESS}"
|
||||
- "--auth-service-url"
|
||||
- "auth:40033"
|
||||
ports:
|
||||
- "40034:40032" # Different host port for staging
|
||||
environment:
|
||||
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
|
||||
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
|
||||
GEMINI_API_KEY: "${GEMINI_API_KEY:-}"
|
||||
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
|
||||
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
|
||||
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
|
||||
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
|
||||
# JWT public key for token validation (auth service handles signing)
|
||||
# Reads from /etc/eagle0/keys/public.pem via shared volume
|
||||
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
|
||||
EAGLE_SAVE_DIR: "/app/saves"
|
||||
EAGLE_ARCHIVE_DIR: "/app/archived"
|
||||
SENTRY_DSN: "${SENTRY_DSN:-}"
|
||||
SENTRY_ENVIRONMENT: "production"
|
||||
volumes:
|
||||
- ./saves:/app/saves # Same save directory as blue
|
||||
- ./archived:/app/archived # Same archive directory as blue
|
||||
- ./jfr:/app/jfr # JFR recordings (same as blue)
|
||||
- jvm-tmp:/tmp # Shared with jfr-sidecar-green for JVM attach socket files
|
||||
- jwt-keys:/etc/eagle0/keys:ro # JWT public key from auth service (read-only)
|
||||
depends_on:
|
||||
- auth
|
||||
restart: "no" # Don't auto-restart during deployment
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "100m"
|
||||
max-file: "5"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "nc -z localhost 40032 || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
start_period: 60s
|
||||
|
||||
# Backward compatibility alias - for scripts that reference 'eagle' service
|
||||
eagle:
|
||||
extends:
|
||||
service: eagle-blue
|
||||
|
||||
auth:
|
||||
image: ${AUTH_IMAGE:-registry.digitalocean.com/eagle0/auth-server:latest}
|
||||
container_name: auth-server
|
||||
environment:
|
||||
# gRPC port for Auth service
|
||||
AUTH_GRPC_PORT: "40033"
|
||||
# HTTP port for OAuth callbacks
|
||||
AUTH_HTTP_PORT: "8080"
|
||||
# User data persistence directory
|
||||
AUTH_DATA_DIR: "/app/data"
|
||||
# Legacy path for migrating users from Eagle (Phase 1 migration)
|
||||
AUTH_LEGACY_DATA_DIR: "/app/saves/auth"
|
||||
# OAuth provider credentials
|
||||
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
|
||||
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
|
||||
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID:-}"
|
||||
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}"
|
||||
GH_OAUTH_CLIENT_ID: "${GH_OAUTH_CLIENT_ID:-}"
|
||||
GH_OAUTH_CLIENT_SECRET: "${GH_OAUTH_CLIENT_SECRET:-}"
|
||||
# Apple Sign-In credentials
|
||||
APPLE_SIGNIN_CLIENT_ID: "${APPLE_SIGNIN_CLIENT_ID:-}"
|
||||
APPLE_TEAM_ID: "${APPLE_TEAM_ID:-}"
|
||||
APPLE_SIGNIN_KEY_ID: "${APPLE_SIGNIN_KEY_ID:-}"
|
||||
APPLE_SIGNIN_PRIVATE_KEY: "${APPLE_SIGNIN_PRIVATE_KEY:-}"
|
||||
# Twitch OAuth credentials
|
||||
TWITCH_CLIENT_ID: "${TWITCH_CLIENT_ID:-}"
|
||||
TWITCH_CLIENT_SECRET: "${TWITCH_CLIENT_SECRET:-}"
|
||||
# Server base URL for OAuth callbacks
|
||||
SERVER_BASE_URL: "${SERVER_BASE_URL:-https://prod.eagle0.net}"
|
||||
# JWT keys - PEM files in volume, bootstrapped from JWK on first run
|
||||
JWT_KEYS_PATH: "/etc/eagle0/keys"
|
||||
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
|
||||
# Fastmail JMAP API for sending invitation emails
|
||||
FASTMAIL_API_TOKEN: "${FASTMAIL_API_TOKEN:-}"
|
||||
FASTMAIL_FROM_EMAIL: "${FASTMAIL_FROM_EMAIL:-}"
|
||||
FASTMAIL_FROM_NAME: "${FASTMAIL_FROM_NAME:-}"
|
||||
# Require invitation codes for new user registration
|
||||
REQUIRE_INVITATION_CODE: "true"
|
||||
# Note: port 40033 is exposed via nginx, not directly
|
||||
volumes:
|
||||
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with Eagle
|
||||
- ./auth-data:/app/data # User database persistence
|
||||
- ./saves:/app/saves:ro # Read-only access to Eagle's saves for migration
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "nc -z localhost 40033 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# Note: Shardok runs on Hetzner ARM64 server, not in this docker-compose.
|
||||
# Configure SHARDOK_ADDRESS to point to the Hetzner instance.
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: nginx
|
||||
ports:
|
||||
- "443:443"
|
||||
- "80:80"
|
||||
- "40033:40033" # Go Auth service gRPC (Phase 2 direct client connections)
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./certbot/conf:/etc/letsencrypt:ro
|
||||
- ./certbot/www:/var/www/certbot:ro
|
||||
- ./auth:/etc/nginx/auth:ro
|
||||
depends_on:
|
||||
- admin
|
||||
# Note: nginx connects to eagle via EAGLE_ADDR (default: eagle-blue:40032)
|
||||
# For blue-green deployments, update EAGLE_ADDR in .env before switching
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
admin:
|
||||
image: ${ADMIN_IMAGE:-registry.digitalocean.com/eagle0/admin-server:latest}
|
||||
container_name: admin-server
|
||||
command:
|
||||
- "--eagle-addr"
|
||||
- "${EAGLE_ADDR:-eagle-blue:40032}" # Can be switched for blue-green
|
||||
- "--auth-addr"
|
||||
- "auth:40033"
|
||||
- "--jfr-sidecar-addr"
|
||||
- "${JFR_SIDECAR_ADDR:-jfr-sidecar:8081}" # Can be switched for blue-green
|
||||
- "--http-port"
|
||||
- "8080"
|
||||
- "--maps-dir"
|
||||
- "/app/maps"
|
||||
environment:
|
||||
# Secret for CI to authenticate client update notifications
|
||||
NOTIFY_SECRET: "${NOTIFY_SECRET:-}"
|
||||
# S3/Spaces credentials for What's New storage (uses eagle0-assets bucket)
|
||||
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
|
||||
DO_SPACES_ACCESS_KEY: "${ADMIN_S3_ACCESS_KEY:-}"
|
||||
DO_SPACES_SECRET_KEY: "${ADMIN_S3_SECRET_KEY:-}"
|
||||
# GitHub PAT for map editor PR creation (scoped to contents:write + pull_requests:write)
|
||||
GITHUB_TOKEN: "${GITHUB_TOKEN:-}"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# No external port - accessed via nginx at admin.eagle0.net
|
||||
depends_on:
|
||||
- auth
|
||||
# Note: admin connects to eagle via EAGLE_ADDR and jfr-sidecar via JFR_SIDECAR_ADDR
|
||||
# For blue-green deployments, set both in .env before switching
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://localhost:8080/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
jfr-sidecar:
|
||||
image: ${JFR_SIDECAR_IMAGE:-registry.digitalocean.com/eagle0/jfr-sidecar:latest}
|
||||
container_name: jfr-sidecar
|
||||
# Share PID namespace with Eagle to access its JVM via jcmd
|
||||
# For blue-green: use JFR_SIDECAR_ADDR=jfr-sidecar-green:8081 when green is active
|
||||
pid: "service:eagle-blue"
|
||||
volumes:
|
||||
- jvm-tmp:/tmp # Shared with Eagle for JVM attach socket files
|
||||
depends_on:
|
||||
- eagle-blue
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "2"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://localhost:8081/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
jfr-sidecar-green:
|
||||
image: ${JFR_SIDECAR_IMAGE:-registry.digitalocean.com/eagle0/jfr-sidecar:latest}
|
||||
container_name: jfr-sidecar-green
|
||||
profiles: ["blue-green"] # Only started during blue-green deployment
|
||||
# Share PID namespace with Eagle green instance
|
||||
pid: "service:eagle-green"
|
||||
volumes:
|
||||
- jvm-tmp:/tmp # Shared with Eagle for JVM attach socket files
|
||||
depends_on:
|
||||
- eagle-green
|
||||
restart: "no" # Don't auto-restart during deployment
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "2"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://localhost:8081/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
certbot:
|
||||
image: certbot/certbot
|
||||
container_name: certbot
|
||||
volumes:
|
||||
- ./certbot/conf:/etc/letsencrypt
|
||||
- ./certbot/www:/var/www/certbot
|
||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
||||
|
||||
volumes:
|
||||
jvm-tmp:
|
||||
# Shared /tmp for JVM attach socket files between Eagle and jfr-sidecar
|
||||
jwt-keys:
|
||||
# Shared JWT RSA keys between Eagle and auth service
|
||||
|
||||
networks:
|
||||
default:
|
||||
driver: bridge
|
||||
enable_ipv6: true
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.28.0.0/16
|
||||
- subnet: fd00:dead:beef::/48
|
||||
@@ -1,242 +0,0 @@
|
||||
# Adding New Quest Types
|
||||
|
||||
This document describes how to add new quest types to Eagle. Quests are tasks that unaffiliated heroes want factions to complete before they'll join.
|
||||
|
||||
## Deployment Strategy: Three-PR Approach
|
||||
|
||||
When adding new quest types, use a three-PR strategy to ensure clients never see quests they don't understand:
|
||||
|
||||
1. **PR 1 - Types Only (Server)**: Add proto definitions, Scala types, converters, and all handling logic (fulfillment, failure, LLM prompts). Do NOT generate the quests yet.
|
||||
|
||||
2. **PR 2 - Client Handling**: Add client-side display code (C#/Unity) that can render the new quest type.
|
||||
|
||||
3. **PR 3 - Quest Generation (Server)**: Add the actual quest creation logic to `QuestCreationUtils.scala`.
|
||||
|
||||
**PR Dependencies:**
|
||||
```
|
||||
PR 1 (types)
|
||||
├── PR 2 (client)
|
||||
└── PR 3 (generation)
|
||||
```
|
||||
|
||||
PR 2 and PR 3 both depend on PR 1, but are independent of each other. They can be developed in parallel after PR 1 is merged, and merged in either order. The key constraint is that PR 3 should not be deployed before PR 2, so clients understand the quest type before the server starts generating it.
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### Server-Side (Scala)
|
||||
|
||||
#### 1. Proto Definition
|
||||
**File:** `src/main/protobuf/net/eagle0/eagle/common/unaffiliated_hero_quest.proto`
|
||||
|
||||
Add a new message type for your quest and add it to the `QuestDetails` oneof:
|
||||
|
||||
```protobuf
|
||||
message MyNewQuest {
|
||||
int32 some_field = 1;
|
||||
string another_field = 2;
|
||||
}
|
||||
|
||||
// In the QuestDetails message, add to the oneof:
|
||||
oneof sealed_value {
|
||||
// ... existing quests ...
|
||||
MyNewQuest my_new_quest = XX; // Use next available field number
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Scala Case Class
|
||||
**File:** `src/main/scala/net/eagle0/eagle/model/state/quest/Quest.scala`
|
||||
|
||||
Add a case class (or case object for quests with no parameters):
|
||||
|
||||
```scala
|
||||
case class MyNewQuest(
|
||||
someField: Int,
|
||||
anotherField: String
|
||||
) extends Quest
|
||||
|
||||
// For quests with multi-part completion, extend ComponentQuest:
|
||||
case class MyComponentQuest(
|
||||
override val componentCount: Int,
|
||||
override val componentsFulfilled: Int,
|
||||
targetValue: Int
|
||||
) extends ComponentQuest {
|
||||
override def withComponentsFulfilled(componentsFulfilled: Int): Quest =
|
||||
this.copy(componentsFulfilled = componentsFulfilled)
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Proto Converter
|
||||
**File:** `src/main/scala/net/eagle0/eagle/model/proto_converters/QuestConverter.scala`
|
||||
|
||||
Add conversions in both `toProto` and `fromProto` methods:
|
||||
|
||||
```scala
|
||||
// In toProto:
|
||||
case q: MyNewQuest =>
|
||||
QuestProto(details = MyNewQuestProto(q.someField, q.anotherField))
|
||||
|
||||
// In fromProto:
|
||||
case MyNewQuestProto(someField, anotherField, _) =>
|
||||
MyNewQuest(someField, anotherField)
|
||||
```
|
||||
|
||||
#### 4. Quest Fulfillment Check
|
||||
**File:** `src/main/scala/net/eagle0/eagle/library/actions/impl/action/CheckForFulfilledQuestsAction.scala`
|
||||
|
||||
Add a case to `didFulfillQuest` that returns `true` when the quest conditions are met:
|
||||
|
||||
```scala
|
||||
case MyNewQuest(someField, anotherField) =>
|
||||
// Return true if quest is fulfilled
|
||||
someConditionIsMet(province, someField)
|
||||
```
|
||||
|
||||
For `ComponentQuest` subclasses, the base case `case q: ComponentQuest => q.componentsFulfilled >= q.componentCount` handles fulfillment automatically.
|
||||
|
||||
#### 5. Quest Failure Check
|
||||
**File:** `src/main/scala/net/eagle0/eagle/library/actions/impl/action/CheckForFailedQuestsAction.scala`
|
||||
|
||||
Add a case to `isQuestFailed` if your quest can fail (e.g., if a required faction is destroyed):
|
||||
|
||||
```scala
|
||||
case MyNewQuest(someField, _) =>
|
||||
// Return true if quest can no longer be completed
|
||||
!factionWithId(someField).exists(_.isActive)
|
||||
```
|
||||
|
||||
Many quests use the default case that returns `false` (quest never fails automatically).
|
||||
|
||||
#### 6. LLM Prompt Generators
|
||||
**File:** `src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators/DivineMessagePromptGenerator.scala`
|
||||
|
||||
Add a case to `describeQuest` for when a soothsayer reveals the quest:
|
||||
|
||||
```scala
|
||||
case MyNewQuest(someField, anotherField) =>
|
||||
TextGenerationSuccess(
|
||||
s"$divinedHeroName wants ${faction.name} to do something with $someField."
|
||||
)
|
||||
```
|
||||
|
||||
**File:** `src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators/QuestEndedGeneratorUtilities.scala`
|
||||
|
||||
Add a case to `pastTenseQuestDescription` for quest fulfilled/failed narratives:
|
||||
|
||||
```scala
|
||||
case MyNewQuest(someField, anotherField) =>
|
||||
for {
|
||||
unaffiliatedHeroName <- unaffiliatedHeroNameResult
|
||||
} yield s"$unaffiliatedHeroName wanted ${faction.name} to do something with $someField."
|
||||
```
|
||||
|
||||
#### 7. Quest Creation (PR 3 only)
|
||||
**File:** `src/main/scala/net/eagle0/eagle/library/util/quest_creation/QuestCreationUtils.scala`
|
||||
|
||||
Add a quest creator function and register it in `availableQuests`:
|
||||
|
||||
```scala
|
||||
private def myNewQuests(
|
||||
province: ProvinceT,
|
||||
allProvinces: Vector[ProvinceT],
|
||||
@unused factions: Vector[FactionT],
|
||||
@unused battalions: Vector[BattalionT],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Vector[Quest]] = {
|
||||
// Return empty if quest shouldn't be available
|
||||
if (!someCondition) RandomState(Vector(), functionalRandom)
|
||||
else {
|
||||
functionalRandom.nextIntInclusive(minValue, maxValue).map { value =>
|
||||
Vector(MyNewQuest(value, "something"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add to availableQuests list:
|
||||
def availableQuests(...) = functionalRandom.nextFlatMap(
|
||||
Vector[QuestCreator](
|
||||
// ... existing creators ...
|
||||
myNewQuests
|
||||
)
|
||||
) { ... }
|
||||
```
|
||||
|
||||
#### 8. Optional: Quest Command Selectors (AI auto-completion)
|
||||
**Directory:** `src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/quest_command_selectors/`
|
||||
|
||||
If you want AI players to automatically work toward completing your quest, create a command chooser.
|
||||
|
||||
### Client-Side (C#/Unity)
|
||||
|
||||
#### 1. Quest Type Display Name
|
||||
**File:** `src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/DisplayNames.cs`
|
||||
|
||||
Add a case to `QuestTypeString`:
|
||||
|
||||
```csharp
|
||||
case SealedValueOneofCase.MyNewQuest: return "My New Quest";
|
||||
```
|
||||
|
||||
#### 2. Quest Description String
|
||||
**File:** `src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/Table Rows/UnaffiliatedHeroRowController.cs`
|
||||
|
||||
Add a case to `ShortQuestString` for displaying the quest in the UI:
|
||||
|
||||
```csharp
|
||||
case SealedValueOneofCase.MyNewQuest: {
|
||||
var details = quest.Details.MyNewQuest;
|
||||
return $"Do something with {details.SomeField}";
|
||||
}
|
||||
```
|
||||
|
||||
For quests involving heroes (where you want dynamic name updates), add a case to `SetQuestText` instead.
|
||||
|
||||
### Build Files
|
||||
|
||||
After making changes, run:
|
||||
|
||||
```bash
|
||||
bazel run gazelle
|
||||
```
|
||||
|
||||
This updates BUILD.bazel files with any new dependencies.
|
||||
|
||||
## Quest Type Categories
|
||||
|
||||
### Simple Quests
|
||||
Quests with fixed completion conditions (e.g., `AllianceQuest`, `SuppressRiotByForceQuest`).
|
||||
|
||||
### Component Quests
|
||||
Quests with multi-part completion that track progress via `componentsFulfilled` / `componentCount` (e.g., `AlmsToProvinceQuest`, `DevelopProvincesQuest`). Extend `ComponentQuest` and implement `withComponentsFulfilled`.
|
||||
|
||||
### Action Quests
|
||||
Quests completed by specific player actions rather than state checks. Return `false` in `didFulfillQuest` and handle completion in the relevant command handler (e.g., `ExecutePrisonerQuest` is fulfilled in prisoner management command).
|
||||
|
||||
## Testing
|
||||
|
||||
1. Build all modified targets:
|
||||
```bash
|
||||
bazel build //src/main/scala/net/eagle0/eagle/model/state/quest:quest
|
||||
bazel build //src/main/scala/net/eagle0/eagle/model/proto_converters:quest_converter
|
||||
```
|
||||
|
||||
2. Run tests:
|
||||
```bash
|
||||
bazel test //src/test/scala/...
|
||||
```
|
||||
|
||||
3. Verify proto/Scala parity:
|
||||
```bash
|
||||
bazel test //src/test/scala/net/eagle0/eagle/model/action_result/types:action_result_type_parity_test
|
||||
```
|
||||
|
||||
## Current Quest Types (30 total)
|
||||
|
||||
- **Diplomacy**: AllianceQuest, TruceWithFactionQuest, TruceCountQuest, DefeatFactionQuest
|
||||
- **Development**: ImproveAgricultureQuest, ImproveEconomyQuest, ImproveInfrastructureQuest, TotalDevelopmentQuest
|
||||
- **Expansion**: SpecificExpansionQuest, ExpandToProvincesQuest
|
||||
- **Military**: GrandArmyQuest, UpgradeBattalionQuest, FightBeastsAloneQuest
|
||||
- **Resources**: WealthQuest, AlmsToProvinceQuest, AlmsAcrossRealmQuest, GiveToHeroesInProvinceQuest, GiveToHeroesAcrossRealmQuest
|
||||
- **Personnel**: DismissSpecificVassalQuest, RescueImprisonedLeaderQuest
|
||||
- **Prisoner**: ExecutePrisonerQuest, ExilePrisonerQuest, ReleasePrisonerQuest, ReturnPrisonerQuest
|
||||
- **Province Orders**: DevelopProvincesQuest, MobilizeProvincesQuest
|
||||
- **Events**: SuppressRiotByForceQuest
|
||||
@@ -1,281 +0,0 @@
|
||||
# Allied Victory: Battle Resolution Flow
|
||||
|
||||
How allied victories work end-to-end, from Shardok tactical resolution through Eagle strategic
|
||||
processing to player-facing aftermath decisions.
|
||||
|
||||
## 1. Battle Setup (Eagle -> Shardok)
|
||||
|
||||
When Eagle sends a battle to Shardok via `RequestBattlesAction`, each player gets specific victory
|
||||
conditions:
|
||||
|
||||
| Player | Victory Conditions |
|
||||
|-------------|-----------------------------------------------------------------------|
|
||||
| Attacker(s) | `HoldsCriticalTiles`, `LastPlayerStanding`, `LastAllianceStanding` |
|
||||
| Defender | `LastPlayerStanding`, `WinAfterMaxRounds` |
|
||||
|
||||
This is set in `RequestBattlesAction.shardokPlayers`.
|
||||
|
||||
Alliance information is sent separately in `PlayerSetupInfo.allies`. Eagle reads each player's
|
||||
`factionRelationships` and includes all non-HOSTILE factions as allies
|
||||
(`ShardokInterfaceGrpcClient.scala:75-80`).
|
||||
|
||||
## 2. Victory Condition Evaluation (Shardok)
|
||||
|
||||
Victory conditions are checked in `UpdateGameStatusAction.cpp` in priority order:
|
||||
|
||||
### Check 1: LastPlayerStanding (any time)
|
||||
|
||||
If exactly **one** player has surviving units and has `LastPlayerStanding`:
|
||||
- That player is the sole winner.
|
||||
- This applies to solo victories only (1 survivor).
|
||||
|
||||
### Check 2: LastAllianceStanding (any time)
|
||||
|
||||
If **multiple** players survive, check if they form a winning alliance:
|
||||
- ALL survivors must have `LastAllianceStanding` condition
|
||||
- ALL survivors must be **mutually** allied (every pair checks both directions)
|
||||
- If yes: all survivors are winners
|
||||
|
||||
This triggers for AssaultProvince battles when allied attackers eliminate all defenders. Since the
|
||||
defender does **not** have `LastAllianceStanding`, the check correctly requires that only the
|
||||
allied attackers remain alive.
|
||||
|
||||
### Check 3: WinAfterMaxRounds (end-of-round only)
|
||||
|
||||
If the round counter exceeds `max_rounds`:
|
||||
- Find the player with `WinAfterMaxRounds` (always the defender)
|
||||
- That player wins, even if they have no surviving units
|
||||
- Eagle validates exactly 1 player has this condition (`internalRequire` in `RequestBattlesAction`)
|
||||
|
||||
### Check 4: HoldsCriticalTiles (end-of-round only)
|
||||
|
||||
**Single-player castle control**: If one player with `HoldsCriticalTiles` occupies ALL critical
|
||||
tiles with hero-bearing units, that player wins. Additionally, any other player who:
|
||||
- Has `HoldsCriticalTiles`
|
||||
- Is **mutually** allied with the castle holder
|
||||
|
||||
is also added to `winning_shardok_ids` as a co-winner.
|
||||
|
||||
**Allied castle control**: If multiple players collectively occupy all critical tiles with
|
||||
hero-bearing units, AND:
|
||||
- All occupants have `HoldsCriticalTiles`
|
||||
- All occupants are mutually allied
|
||||
|
||||
Then all occupants are winners together.
|
||||
|
||||
## 3. EndGameCondition Assignment Per Player (Shardok -> Eagle)
|
||||
|
||||
Shardok assigns each player a `Victory` or `Loss`. All winners receive `Victory`; there is no
|
||||
`AllyVictory` distinction. The `EndGameCondition` proto reserves fields 2 (`ally_victory`) and
|
||||
3 (`draw`) for backwards compatibility but Eagle rejects both.
|
||||
|
||||
`GameOverResponsePopulator` validates that any player allied to a winner is also in
|
||||
`winning_shardok_ids`. If an ally is missing from the winners list, it throws an internal error.
|
||||
|
||||
**Known issue**: This validation is too strict for `LastPlayerStanding`. When one allied attacker
|
||||
is the sole survivor, their dead co-attacker is allied to the winner but legitimately not in
|
||||
`winning_shardok_ids` (they're dead). The throw should be changed to `Loss` for dead allies, or
|
||||
the check should only apply when the ally has surviving units.
|
||||
|
||||
| Condition | Criterion |
|
||||
|-----------|-----------|
|
||||
| `Victory(type)` | Player is in `winning_shardok_ids` |
|
||||
| `Loss(type)` | Player is not in `winning_shardok_ids` |
|
||||
|
||||
The `type` is the VictoryCondition that caused the game to end (e.g., `HoldsCriticalTiles`).
|
||||
|
||||
### When do multiple players get Victory?
|
||||
|
||||
In standard AssaultProvince battles:
|
||||
|
||||
1. **Allied attackers jointly hold all castles** -> both get `Victory(HoldsCriticalTiles)`.
|
||||
|
||||
2. **One attacker holds all castles alone, ally has `HoldsCriticalTiles` and is mutually allied**
|
||||
-> both get `Victory(HoldsCriticalTiles)`. The ally is added to `winning_shardok_ids` by
|
||||
`UpdateGameStatusAction`.
|
||||
|
||||
3. **Allied attackers eliminate all defenders** -> both get `Victory(LastAllianceStanding)`.
|
||||
This triggers because both have `LastAllianceStanding` and are mutually allied.
|
||||
|
||||
4. **One attacker holds all castles, co-attacker is NOT allied** -> only the castle holder
|
||||
gets `Victory(HoldsCriticalTiles)`; the non-allied co-attacker gets `Loss`.
|
||||
|
||||
## 4. Eagle Processes Battle Results
|
||||
|
||||
`ResolveBattleAction.scala` receives `BattleResolution` containing each player's
|
||||
`EndGameCondition` and resolved units.
|
||||
|
||||
### Winner/Loser Partition
|
||||
|
||||
Players are split by `isVictory` (line 383-386):
|
||||
- `winningResolvedPlayers`: Victory
|
||||
- `losingResolvedPlayers`: Loss
|
||||
|
||||
### The `unitReturned` Function (line 765-788)
|
||||
|
||||
Determines which units go home vs stay at the battle province:
|
||||
|
||||
| Unit Status | Returned? | Notes |
|
||||
|---------------|-----------|-------|
|
||||
| `Fled` | Always | Sent to their army's flee province |
|
||||
| `NeverEntered`| Conditional | Only if: attacker AND has flee province AND did NOT win (`!isVictory`) |
|
||||
| `Captured` | Never | |
|
||||
| `Normal` | Never | |
|
||||
| `Retreated` | Never | |
|
||||
| `Outlawed` | Never | Becomes unaffiliated hero |
|
||||
|
||||
Key point: `NeverEntered` units from **winning** factions stay at the battle province rather than
|
||||
being sent home. This is important for bounced co-attackers who are allied with the actual winner.
|
||||
|
||||
### Withdrawn/Fled Unit Routing (line 690-706)
|
||||
|
||||
For units that ARE returned, `armyFromResolvedArmy` builds a returning army with
|
||||
`fleeProvinceId = None`. If the army has no `fleeProvinceId`, it becomes "shattered" (units
|
||||
become outlaws in the battle province). If the original army had a `fleeProvinceId` set, returned
|
||||
units are sent there as incoming armies.
|
||||
|
||||
### Battle Resolution Branching (line 397-552)
|
||||
|
||||
Three branches based on who won:
|
||||
|
||||
#### Branch A: Defender Won
|
||||
|
||||
Triggered when: `winningShardokPlayers.exists(_.isDefender)`
|
||||
|
||||
- Executes `ProvinceHeldAction`
|
||||
- All non-defender, non-returned, non-outlawed units are **captured**
|
||||
- Province stays with the defending faction
|
||||
|
||||
#### Branch B: Single Attacker Won
|
||||
|
||||
Triggered when: exactly 1 attacking winner
|
||||
|
||||
- Executes `ProvinceConqueredAction` immediately
|
||||
- Winning attacker's non-fled units occupy the province
|
||||
- Losing defenders' non-fled units are captured
|
||||
- Province transfers to the attacker
|
||||
|
||||
#### Branch C: Multiple Allied Attackers Won
|
||||
|
||||
Triggered when: 2+ attacking winners
|
||||
|
||||
- Executes `MultiVictorBattleSetupAction`
|
||||
- Creates `PendingConquestInfo` with aftermath claimants
|
||||
- Province enters the **Battle Aftermath** phase
|
||||
|
||||
## 5. Battle Aftermath (Multi-Victor Only)
|
||||
|
||||
When multiple allied attackers win, the province enters an aftermath decision phase where players
|
||||
decide who keeps it.
|
||||
|
||||
### Claimant Setup
|
||||
|
||||
Each attacking winner becomes an `AftermathClaimant` with:
|
||||
- `units`: their non-fled, non-outlawed units still at the battle province
|
||||
- `armySize`: total troop count of those units
|
||||
- `broughtGold` / `broughtFood`: supplies their armies carried
|
||||
|
||||
Claimants are **sorted by army size descending** (largest army decides first).
|
||||
|
||||
### Decision Phase
|
||||
|
||||
Claimants are presented with a choice **one at a time**, in order:
|
||||
|
||||
**If no one has chosen "Keep" yet:**
|
||||
- **Keep Province**: claim the province for your faction
|
||||
- **Withdraw To**: pick an adjacent province to march your army to, optionally carrying up to
|
||||
the gold/food you brought
|
||||
|
||||
**If someone already chose "Keep":**
|
||||
- **Withdraw To** is the only option (can't have two keepers)
|
||||
|
||||
**Last undecided claimant:**
|
||||
- If no one has chosen "Keep" yet, the last claimant **automatically keeps** (no command shown,
|
||||
resolved by `AutoResolveBattleAftermathAction`)
|
||||
- This guarantees someone always claims the province
|
||||
|
||||
### Finalization
|
||||
|
||||
Once all claimants have decided (`FinalizeAftermathAction`):
|
||||
|
||||
1. The keeper's units run through `ProvinceConqueredAction` -- province transfers to their faction
|
||||
2. Withdrawing claimants' units become incoming armies to their chosen adjacent province,
|
||||
arriving next round, carrying the specified supplies
|
||||
3. `PendingConquestInfo` is cleared from the province
|
||||
|
||||
## 6. Summary: Who Gets What
|
||||
|
||||
### Two allied attackers jointly hold all castles
|
||||
- Shardok: Both get `Victory(HoldsCriticalTiles)` (both in `winning_shardok_ids`)
|
||||
- Eagle: Both are attacking winners -> MultiVictorBattleSetupAction
|
||||
- Players: Largest army picks first: Keep or Withdraw. Last player auto-keeps if no one chose Keep.
|
||||
|
||||
### One attacker holds all castles, allied attacker doesn't
|
||||
- Shardok: Castle-holder gets `Victory(HoldsCriticalTiles)`. Mutually-allied co-attacker with
|
||||
`HoldsCriticalTiles` is added to `winning_shardok_ids` -> also gets `Victory(HoldsCriticalTiles)`.
|
||||
- Eagle: Both are attacking winners -> MultiVictorBattleSetupAction -> Battle Aftermath.
|
||||
- Players: Largest army picks first: Keep or Withdraw.
|
||||
|
||||
### Allied attackers eliminate all defenders
|
||||
- Shardok: Both attackers have `LastAllianceStanding` and are mutually allied -> both get
|
||||
`Victory(LastAllianceStanding)`.
|
||||
- Eagle: Both are attacking winners -> MultiVictorBattleSetupAction -> Battle Aftermath.
|
||||
- Players: Largest army picks first: Keep or Withdraw.
|
||||
|
||||
### Non-allied co-attackers: one holds all castles
|
||||
- Shardok: Castle-holder gets `Victory(HoldsCriticalTiles)`, non-allied co-attacker gets
|
||||
`Loss(HoldsCriticalTiles)`.
|
||||
- Eagle: Single attacker won -> ProvinceConqueredAction. Non-allied co-attacker's units are
|
||||
captured along with the defender's.
|
||||
|
||||
### One allied attacker is sole survivor (LastPlayerStanding)
|
||||
- Shardok: Sole survivor gets `Victory(LastPlayerStanding)`. Dead ally should get `Loss`.
|
||||
- **BUG**: `GameOverResponsePopulator` currently throws because dead ally is allied to winner
|
||||
but not in `winning_shardok_ids`. Needs fix — see known issue in Section 3.
|
||||
- Eagle (once fixed): Single attacker won -> ProvinceConqueredAction. Dead ally's units were
|
||||
already destroyed in battle.
|
||||
|
||||
### Single attacker wins (any condition, no co-attackers)
|
||||
- No alliance considerations. ProvinceConqueredAction immediately.
|
||||
|
||||
### Defender wins (WinAfterMaxRounds or LastPlayerStanding)
|
||||
- ProvinceHeldAction. All non-returned attacker units are captured.
|
||||
|
||||
## 7. Edge Cases and Known Issues
|
||||
|
||||
### BUG: Dead ally causes crash via LastPlayerStanding
|
||||
|
||||
When one allied attacker is the sole survivor (both the co-attacker and defender are dead),
|
||||
`LastPlayerStanding` fires and only the survivor is in `winning_shardok_ids`. The
|
||||
`GameOverResponsePopulator` then sees the dead co-attacker is allied to the winner but not in
|
||||
`winning_shardok_ids`, and throws `ShardokInternalErrorException`. Fix: dead allies should
|
||||
receive `Loss`, not trigger a validation error.
|
||||
|
||||
### Non-allied co-attackers eliminate all defenders
|
||||
|
||||
Two non-allied attackers who both survive after eliminating the defender cannot win via
|
||||
`LastAllianceStanding` (they aren't mutually allied). The game continues until max rounds,
|
||||
at which point the defender wins via `WinAfterMaxRounds`. They must capture all critical tiles.
|
||||
|
||||
### Non-allied co-attackers: one holds all castles
|
||||
|
||||
Only the castle holder wins. The non-allied co-attacker gets `Loss` and their units are captured
|
||||
along with the defender's. The mutual-alliance check in `UpdateGameStatusAction` prevents
|
||||
non-allied players from being added to `winning_shardok_ids`.
|
||||
|
||||
## Key File References
|
||||
|
||||
| Component | File |
|
||||
|-----------|------|
|
||||
| Victory condition checking | `src/main/cpp/net/eagle0/shardok/library/actions/UpdateGameStatusAction.cpp` |
|
||||
| Per-player EndGameCondition | `src/main/cpp/net/eagle0/shardok/server/GameOverResponsePopulator.cpp` |
|
||||
| Battle setup (victory conditions) | `src/main/scala/.../library/actions/impl/action/RequestBattlesAction.scala` |
|
||||
| Alliance communication | `src/main/scala/.../shardok_interface/ShardokInterfaceGrpcClient.scala:75-80` |
|
||||
| Battle result processing | `src/main/scala/.../library/actions/impl/action/ResolveBattleAction.scala` |
|
||||
| Unit return logic | `ResolveBattleAction.scala:765-788` (`unitReturned`) |
|
||||
| Multi-victor setup | `src/main/scala/.../library/actions/impl/action/MultiVictorBattleSetupAction.scala` |
|
||||
| Aftermath availability | `src/main/scala/.../library/actions/availability/AvailableBattleAftermathDecisionCommandFactory.scala` |
|
||||
| Aftermath command | `src/main/scala/.../library/actions/impl/command/BattleAftermathDecisionCommand.scala` |
|
||||
| Auto-resolve last claimant | `src/main/scala/.../library/actions/impl/action/AutoResolveBattleAftermathAction.scala` |
|
||||
| Finalize aftermath | `src/main/scala/.../library/actions/impl/action/FinalizeAftermathAction.scala` |
|
||||
| EndGameCondition enum | `src/main/scala/.../model/state/shardok_battle/EndGameCondition.scala` |
|
||||
@@ -1,330 +0,0 @@
|
||||
# Eagle0 Media Asset Audit
|
||||
|
||||
This document catalogs all media assets in the Unity project for licensing review.
|
||||
|
||||
**Total Assets:** ~12,500 files | **Size:** 1.4 GB
|
||||
|
||||
---
|
||||
|
||||
## Summary by Category
|
||||
|
||||
| Category | Count | Notes |
|
||||
|----------|-------|-------|
|
||||
| Images | 10,637 | Mostly PNG icons and UI sprites |
|
||||
| Audio | 1,778 | 26 music tracks + 1,752 sound effects |
|
||||
| 3D Models | 66+ | Bridge pack, Animal pack deluxe, Honey Badger |
|
||||
| Fonts | 16 | TTF files |
|
||||
|
||||
---
|
||||
|
||||
## 1. Purchased Asset Store Packages
|
||||
|
||||
These are commercial Unity Asset Store purchases tied to your account:
|
||||
|
||||
### 4000_Fantasy_Icons
|
||||
- **Location:** `Assets/4000_Fantasy_Icons/`
|
||||
- **Size:** 495 MB (5,621 PNG files)
|
||||
- **Contents:** Icons for armor, weapons, skills, resources
|
||||
- **License:** Unity Asset Store (check invoice/account)
|
||||
|
||||
### GUI Pro Kit Fantasy RPG
|
||||
- **Location:** `Assets/GUI Pro Kit Fantasy RPG/`
|
||||
- **Size:** 117 MB (3,755 PNG files)
|
||||
- **Contents:** UI sprites, animations, prefabs
|
||||
- **Includes fonts:** Alata-Regular.ttf, JosefinSans-Bold.ttf
|
||||
- **License:** Unity Asset Store
|
||||
|
||||
### Modern UI Pack v4.2.0
|
||||
- **Location:** `Assets/Modern UI Pack/`
|
||||
- **Size:** 40 MB (191 PNG files)
|
||||
- **Author:** Michsky (support@michsky.com)
|
||||
- **Website:** https://www.michsky.com
|
||||
- **Includes fonts:** Open Sans family (12 variants)
|
||||
- **License:** Unity Asset Store
|
||||
|
||||
### Pixel Fonts Megapack
|
||||
- **Location:** `Assets/Pixel Fonts Megapack/`
|
||||
- **Publisher ID:** 17384
|
||||
- **Author:** @pixelmush_ on Twitter
|
||||
- **Asset Store Link:** http://u3d.as/w4v
|
||||
- **License:** Unity Asset Store
|
||||
|
||||
### TileableBridgePack
|
||||
- **Location:** `Assets/TileableBridgePack/`
|
||||
- **Size:** 3.1 MB (52 FBX models)
|
||||
- **Contents:** Bridge construction pieces
|
||||
- **License:** Unity Asset Store
|
||||
|
||||
### Animal pack deluxe
|
||||
- **Location:** `Assets/Animal pack deluxe/`
|
||||
- **Publisher:** janpec
|
||||
- **Asset Store Link:** https://assetstore.unity.com/packages/3d/characters/animals/animal-pack-deluxe-99702
|
||||
- **Contents:** 26 rigged and animated 3D animal models (bear, boar, crocodile, wolf, frog, crab, rabbit, scorpion, snake, stag, deer, rat, goat, pig, etc.) with idle, walk, run, eat, attack, and die animations
|
||||
- **Used for:** Beast province effects on the strategic map (AnimalEffect)
|
||||
- **License:** Unity Asset Store
|
||||
|
||||
### 2D Monster Pack: Basic Bundle (+PSB)
|
||||
- **Location:** `Assets/DungeonMonsters2D/`
|
||||
- **Publisher:** SP1
|
||||
- **Asset Store Link:** https://assetstore.unity.com/packages/2d/characters/2d-monster-pack-basic-bundle-psb-328637
|
||||
- **Contents:** 2D animated monster sprites with PSB (Photoshop) source files. Characters: SkeletonWarrior, SkeletonArcher, SkeletonMage, Zombie, ZombieWarrior, Demon, Succubus, Imp, Spider, Rat, Vampire, DragonRed, Butcher
|
||||
- **Used for:** Beast province effects (MonsterEffect, DragonEffect) and human-type beast effects on the strategic map
|
||||
- **License:** Unity Asset Store
|
||||
|
||||
### Honey Badger 3D Model
|
||||
- **Location:** `Assets/HoneyBadger/`
|
||||
- **Creator:** WildMesh 3D (@WildMesh_3D)
|
||||
- **Source:** https://sketchfab.com/3d-models/realistic-honey-badger-3d-model-09751ed5383c4afb924cfb6485d313f1
|
||||
- **Contents:** Rigged 3D honey badger model (FBX) with 63 animations and PBR texture
|
||||
- **Used for:** Honey badger beast province effect (AnimalEffect)
|
||||
- **License:** Purchased via Sketchfab/Patreon
|
||||
|
||||
### Fantasy Interface Sounds
|
||||
- **Location:** `Assets/Fantasy Interface Sounds/`
|
||||
- **Count:** 320 WAV files
|
||||
- **Contents:** UI sounds (bag, book, coins, dice, etc.)
|
||||
- **License:** Unity Asset Store (verify)
|
||||
|
||||
### Medieval Combat Sounds
|
||||
- **Location:** `Assets/Medieval Combat Sounds/`
|
||||
- **Count:** 1,072 WAV files
|
||||
- **Contents:** Footsteps, swings, shields, weapons, magic
|
||||
- **License:** Unity Asset Store (verify)
|
||||
|
||||
### Magic Spells Sound Effects LITE
|
||||
- **Location:** `Assets/Magic Spells Sound Effects LITE/`
|
||||
- **Count:** 254 WAV files
|
||||
- **Contents:** Spell casting, element effects
|
||||
- **Note:** "LITE" version - check if restrictions apply
|
||||
- **License:** Unity Asset Store (verify)
|
||||
|
||||
---
|
||||
|
||||
## 2. Creative Commons Music (Properly Licensed)
|
||||
|
||||
**Location:** `Assets/Resources/Music/`
|
||||
**Documentation:** `Music Credits.txt` (attribution file exists)
|
||||
|
||||
All 26 tracks have CC licenses with proper attribution:
|
||||
|
||||
| Track | Artist | License |
|
||||
|-------|--------|---------|
|
||||
| A Robust Crew | Darren Curtis | CC BY 3.0 |
|
||||
| Asian Graveyard | Darren Curtis | CC BY 3.0 |
|
||||
| Fall From Grace | Darren Curtis | CC BY 3.0 |
|
||||
| Samurai Sake Showdown | Darren Curtis | CC BY 3.0 |
|
||||
| Deflector | Ghostrifter Official | CC BY-SA 3.0 |
|
||||
| Chase | Alexander Nakarada | CC BY 4.0 |
|
||||
| Wintersong | Alexander Nakarada | CC BY 4.0 |
|
||||
| One Bard Band | Alexander Nakarada | CC BY 4.0 |
|
||||
| Now We Ride | Alexander Nakarada | CC BY 4.0 |
|
||||
| The Northern Path | Alexander Nakarada | CC BY 4.0 |
|
||||
| Victory | MaxKoMusic | CC BY-SA 3.0 |
|
||||
| Sakuya2 | PeriTune | CC BY 3.0 |
|
||||
| Under The Sun | Keys of Moon | CC BY 4.0 |
|
||||
| One Piece of Summer | Keys of Moon | CC BY 4.0 |
|
||||
| Fluffing a Duck | Kevin MacLeod | CC BY 3.0 |
|
||||
| Space Jazz | Kevin MacLeod | CC BY 3.0 |
|
||||
| The Ice Giants | Kevin MacLeod | CC BY 4.0 |
|
||||
| Epic Cinematic Trailer ELITE | Alex-Productions | CC BY 3.0 |
|
||||
| Push | Alex-Productions | CC BY 3.0 |
|
||||
| Virus | Alex-Productions | CC BY 3.0 |
|
||||
| Duel | Makai Symphony | CC BY-SA 3.0 |
|
||||
| Dragon Castle | Makai Symphony | CC BY-SA 3.0 |
|
||||
| Durandal | Makai Symphony | CC BY-SA 3.0 |
|
||||
|
||||
**Tracks with non-CC licenses:**
|
||||
|
||||
| Track | Artist | License | Source |
|
||||
|-------|--------|---------|--------|
|
||||
| Market Day | RandomMind | Free without attribution | [Chosic](https://www.chosic.com/download-audio/27016/) |
|
||||
| Shopping List | Komiku | Free without attribution | [Chosic](https://www.chosic.com/download-audio/24714/) |
|
||||
| Medieval: Victory Theme | RandomMind | CC0 Public Domain | [Chosic](https://www.chosic.com/download-audio/28492/) |
|
||||
| No Time for Greatness | Dima Koltsov | CC BY 4.0 | [YouTube](https://www.youtube.com/watch?v=cQh0OWIFdgM) |
|
||||
| Warriors of Demacia | Dima Koltsov | CC BY 4.0 | [YouTube](https://www.youtube.com/watch?v=yktSUMJn9ao) |
|
||||
| Forest Queen Tale | Dima Koltsov | Presumed CC BY 4.0 | Not found on YouTube; other Dima Koltsov tracks are CC BY 4.0 |
|
||||
| Valor | Dima Koltsov | CC BY 4.0 | [YouTube](https://www.youtube.com/watch?v=uoHYJRPcS2Y) |
|
||||
| Clouds | Dima Koltsov | Presumed CC BY 4.0 | Not found on YouTube; other Dima Koltsov tracks are CC BY 4.0 |
|
||||
|
||||
**Note on Dima Koltsov tracks:** 3 of 5 tracks confirmed CC BY 4.0 via YouTube. 2 remaining tracks (Forest Queen Tale, Clouds) presumed same license but not verified.
|
||||
|
||||
---
|
||||
|
||||
## 2b. Creative Commons Sound Effects
|
||||
|
||||
**Location:** `Assets/Shardok/Sounds/`
|
||||
|
||||
| File | Description | Artist | License | Source |
|
||||
|------|-------------|--------|---------|--------|
|
||||
| `rain_loop.ogg` | Rain falling on clay roof tiles (loopable) | aesqe | CC BY 4.0 | [Freesound #37618](https://freesound.org/people/aesqe/sounds/37618/) |
|
||||
| `blizzard_wind_loop.wav` | Wind draft loop (indoor recording, loops seamlessly) | nsstudios | CC BY 4.0 | [Freesound #651540](https://freesound.org/people/nsstudios/sounds/651540/) |
|
||||
| `thunder_distant.mp3` | Distant thunder rumble | LittleRainySeasons | CC BY 4.0 | [Freesound #351526](https://freesound.org/people/LittleRainySeasons/sounds/351526/) |
|
||||
| `thunder_loud.mp3` | Loud thunder clap | mokasza | CC BY 4.0 | [Freesound #810746](https://freesound.org/people/mokasza/sounds/810746/) |
|
||||
| `thunder_crack.wav` | Thunder crack | OneSoundToRuleThemAll | CC BY 4.0 | [Freesound #238796](https://freesound.org/people/OneSoundToRuleThemAll/sounds/238796/) |
|
||||
| `thunder_clap.wav` | Thunder clap | FreqMan | CC BY 4.0 | [Freesound #32544](https://freesound.org/people/FreqMan/sounds/32544/) |
|
||||
|
||||
**Location:** `Assets/Shardok/soundEffects/`
|
||||
|
||||
| File | Description | Artist | License | Source |
|
||||
|------|-------------|--------|---------|--------|
|
||||
| `runaway.mp3` | Medieval army running loop (gravel + metal/chain) | Yap_Audio_Production | CC BY 4.0 | [Freesound #218997](https://freesound.org/people/Yap_Audio_Production/sounds/218997/) |
|
||||
|
||||
---
|
||||
|
||||
## 3. CC0 / Public Domain Assets
|
||||
|
||||
### SimpleFileBrowser Icons
|
||||
- **Location:** `Assets/Plugins/SimpleFileBrowser/Sprites/FileIcons/`
|
||||
- **License:** CC0 (documented in LICENSE.txt)
|
||||
- **Source:** pngrepo.com
|
||||
- **Items:** Archive, Audio, Default, Drive, Folder, Image, PDF, Text, Video icons
|
||||
|
||||
---
|
||||
|
||||
## 4. Potentially Problematic Assets (Review Needed)
|
||||
|
||||
### ~~Clip Art (Unknown License)~~ RESOLVED
|
||||
| File | Status |
|
||||
|------|--------|
|
||||
| ~~`Assets/Shardok/commandImages/bridge.png`~~ | **REPLACED** (2026-01-23) with AI-generated wooden rope bridge icon (ChatGPT/DALL-E 3, 512x512 PNG). No licensing restrictions - AI-generated for this project. |
|
||||
| ~~`Assets/Images/startFire.png`~~ | **REPLACED** (2026-01-23) with "Flame Icon" from [UXWing](https://uxwing.com/flame-icon/) (free for commercial use, no attribution required). Consolidated duplicate removed. |
|
||||
| ~~`Assets/Shardok/commandImages/startFire.png`~~ | **DELETED** (2026-01-23) - duplicate removed, all references updated to use `Assets/Images/startFire.png` |
|
||||
|
||||
### Shardok Sound Effects
|
||||
- **Location:** `Assets/Shardok/soundEffects/`
|
||||
- **Count:** 37 audio files (was incorrectly counted as 56 including .meta files)
|
||||
- **Contents:** Spell effects, movement, combat sounds
|
||||
|
||||
**Verified from [Zombie Monster - Undead Collection](https://assetstore.unity.com/packages/audio/sound-fx/creatures/zombie-monster-undead-collection-70662) (Unity Asset Store):**
|
||||
- `raise_undead.mp3`
|
||||
- `undead_break_control.mp3`
|
||||
- `undead_grew.wav`
|
||||
|
||||
**⚠️ MUST REPLACE (1 remaining):**
|
||||
- ~~`anybody.mp3`~~ - **REPLACED** (2026-01-23) with `Positive Effect 6.wav` from Magic Spells Sound Effects LITE
|
||||
- ~~`burnination.mp3`~~ - **REPLACED** (2026-01-23) with `Magic Element Fire 04.wav` from Medieval Combat Sounds
|
||||
- `failure_horn.mp3` - licensing issue, no replacement found in purchased assets
|
||||
- ~~`runaway.mp3`~~ - **REPLACED** (2026-01-27) with `MedievalArmyRunningLoop.mp3` from Freesound (CC BY 4.0)
|
||||
|
||||
**Verified from [Medieval Combat Sounds](https://assetstore.unity.com/packages/audio/sound-fx/medieval-combat-sounds-100465) (Unity Asset Store):**
|
||||
- `duel_challenged.wav` - **REPLACED** (2026-02-03) with `Weapon Draw Metal 1.wav` from Medieval Combat Sounds
|
||||
|
||||
**Presumed from Unity Asset Store purchases (30 files):**
|
||||
Owner believes these are from: Fantasy Interface Sounds, Medieval Combat Sounds, Magic Spells Sound Effects LITE, and/or Medieval Battle Sound Pack.
|
||||
- `archery.mp3`, `battle_shout.mp3`, `boo.mp3`, `braved_water.mp3`
|
||||
- `build_bridge.mp3`, `build_bridge_failure.mp3`, `charge.mp3`
|
||||
- `dismiss_unit.mp3`, `failure_horn.mp3`, `fear.mp3`, `fear_failed.mp3`
|
||||
- `fire_extinguish.mp3`, `fire_spread.mp3`, `fire_start.mp3`, `fire_start_failure.mp3`
|
||||
- `freeze.mp3`, `holy_wave.mp3`, `holy_wave_damage.mp3`, `jail_door.mp3`, `lightning.mp3`
|
||||
- `melee.mp3`, `meteor.mp3`, `mind_control.mp3`, `move.mp3`, `move 1.mp3`
|
||||
- `raging_fire.mp3`, `reduce.mp3`, `repair.mp3`, `repair_failed.mp3`, `splash.mp3`
|
||||
|
||||
### ~~Free Icons~~ RESOLVED
|
||||
- **Location:** `Assets/free_icons/` - **DELETED** (2026-01-27)
|
||||
- **Resolution:** All icons replaced with equivalents from purchased Asset Store packs:
|
||||
- `blizzard.png` → `16_blizzard_nobg.png` (4000_Fantasy_Icons)
|
||||
- `rain.png` → `12_Magic_rain_nobg.png` (4000_Fantasy_Icons)
|
||||
- `thunderstorm.png` → `27_Storm_nobg.png` (4000_Fantasy_Icons)
|
||||
- `wind.png` → `23_Light_blow_nobg.png` (4000_Fantasy_Icons)
|
||||
- `thermometer.png` → `startFire.png` (existing licensed asset)
|
||||
- `snow.png` → `16_blizzard_nobg.png` (4000_Fantasy_Icons)
|
||||
- `cloud.png`, `sun.png` → deleted (unused)
|
||||
|
||||
### ~~Terrain Hexes~~ VERIFIED
|
||||
- **Location:** `Assets/Terrain Hexes/`
|
||||
- **Count:** 85 PNG files
|
||||
- **Status:** ✓ Confirmed Unity Asset Store purchase (2026-01-23)
|
||||
|
||||
### ~~StrategyGameIcons~~ VERIFIED
|
||||
- **Location:** `Assets/StrategyGameIcons/`
|
||||
- **Count:** 138 PNG files
|
||||
- **Publisher:** REXARD
|
||||
- **Asset Store Link:** https://assetstore.unity.com/packages/2d/gui/icons/strategy-game-icons-64816
|
||||
- **Status:** ✓ Confirmed Unity Asset Store purchase (2026-01-23)
|
||||
|
||||
---
|
||||
|
||||
## 5. Fonts
|
||||
|
||||
| Font | Location | License |
|
||||
|------|----------|---------|
|
||||
| Open Sans (12 variants) | Modern UI Pack | Apache 2.0 (Google Font) |
|
||||
| Alata-Regular | GUI Pro Kit | SIL OFL (Google Font) |
|
||||
| JosefinSans-Bold | GUI Pro Kit | SIL OFL (Google Font) |
|
||||
| LiberationSans | TextMesh Pro | SIL OFL |
|
||||
| NotoColorEmoji | Assets root | SIL OFL (Google) |
|
||||
| Stoke-Light, Stoke-Regular | Assets root | SIL OFL (Google Font) |
|
||||
|
||||
All fonts appear to be open-source Google Fonts or Liberation fonts - should be fine.
|
||||
|
||||
---
|
||||
|
||||
## 6. Third-Party Code Packages
|
||||
|
||||
NuGet packages in `Assets/Packages/` all include LICENSE.TXT files:
|
||||
- Microsoft.Extensions.* - MIT License
|
||||
- System.* - MIT License
|
||||
- Grpc.* - Apache 2.0
|
||||
|
||||
---
|
||||
|
||||
## Action Items
|
||||
|
||||
### Must Replace Before Opening Public Access:
|
||||
|
||||
1. ~~**Clip art images**~~ - **RESOLVED** (2026-01-23): Replaced with properly licensed alternatives
|
||||
|
||||
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
|
||||
|
||||
### Low Priority (Verify):
|
||||
|
||||
3. **Dima Koltsov tracks** - 2 of 5 not verified: `Forest Queen Tale`, `Clouds` (presumed CC BY 4.0 like his other tracks)
|
||||
|
||||
### Already Resolved:
|
||||
|
||||
4. ~~**Free Icons**~~ - **RESOLVED** (2026-01-27): All replaced with Asset Store equivalents, folder deleted
|
||||
|
||||
5. ~~**Terrain Hexes**~~ - **VERIFIED** (2026-01-23): Confirmed Unity Asset Store purchase
|
||||
|
||||
6. ~~**StrategyGameIcons**~~ - **VERIFIED** (2026-01-23): Unity Asset Store purchase (REXARD)
|
||||
|
||||
7. ~~**Medieval: Victory Theme**~~ - **VERIFIED** (2026-01-23): CC0 Public Domain by RandomMind ([Chosic](https://www.chosic.com/download-audio/28492/))
|
||||
|
||||
8. ~~**Discord logo**~~ - **OK** (2026-01-23): Usage complies with Discord brand guidelines for "Login with Discord" button
|
||||
|
||||
### Already Safe:
|
||||
|
||||
- All Asset Store purchases (license tied to your account)
|
||||
- CC-licensed music (attribution in Music Credits.txt)
|
||||
- CC0 SimpleFileBrowser icons
|
||||
- Google Fonts / Liberation fonts
|
||||
- NuGet packages
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Remaining before public release:**
|
||||
|
||||
None! All required items resolved.
|
||||
|
||||
**Low priority:**
|
||||
|
||||
2. Verify 2 Dima Koltsov tracks (`Forest Queen Tale`, `Clouds`) - presumed CC BY 4.0
|
||||
|
||||
**Already resolved:**
|
||||
|
||||
- ~~Clip art images~~ **DONE** - replaced with properly licensed alternatives
|
||||
- ~~Terrain Hexes~~ **DONE** - confirmed Asset Store purchase
|
||||
- ~~StrategyGameIcons~~ **DONE** - Unity Asset Store (REXARD)
|
||||
- ~~Medieval: Victory Theme~~ **DONE** - CC0 Public Domain
|
||||
- ~~3 other Dima Koltsov tracks~~ **DONE** - confirmed CC BY 4.0
|
||||
- ~~anybody.mp3, burnination.mp3~~ **DONE** - replaced
|
||||
- ~~free_icons~~ **DONE** - replaced with Asset Store equivalents
|
||||
- ~~failure_horn.mp3~~ **DONE** - replaced with Negative Effect 04.wav
|
||||
|
||||
The bulk of your assets (95%+) are properly licensed Asset Store purchases or CC content.
|
||||
@@ -1,246 +0,0 @@
|
||||
# Adding Beast-Type-Specific Effects
|
||||
|
||||
When a province has a `BeastsEvent`, `ProvinceBeastsController` spawns a visual effect at the province centroid. By default this is the generic `BeastsEffect` (circling vultures/crows). Beast-specific effects can override this based on `BeastInfo.SingularName`.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
ProvinceBeastsController
|
||||
├── GetBeastName(province) → reads BeastsEvent.BeastInfo.SingularName
|
||||
├── GetEffectPrefab(beastName) → switch on name → returns prefab
|
||||
└── SpawnBeastsEffect(provinceId, beastName) → instantiates at centroid
|
||||
```
|
||||
|
||||
**Key files:**
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Assets/Eagle/ProvinceBeastsController.cs` | Routes beast names to prefabs, positions effects |
|
||||
| `Assets/Eagle/BeastsEffect.cs` | Generic effect (circling birds via particle system) |
|
||||
| `Assets/Eagle/DragonEffect.cs` | Dragon-specific effect (3D model with flying/ground modes) |
|
||||
| `Assets/Eagle/MonsterEffect.cs` | Wandering 2D monster group (DungeonMonsters2D prefabs) |
|
||||
| `Assets/Eagle/AnimalEffect.cs` | Wandering 3D animal group (Animal Pack Deluxe prefabs) |
|
||||
| `Assets/Eagle/Effects/` | All effect prefabs |
|
||||
|
||||
## Adding a new beast type
|
||||
|
||||
### 1. Create the effect MonoBehaviour (or reuse an existing one)
|
||||
|
||||
Three effect scripts exist for different asset types:
|
||||
|
||||
**MonsterEffect** (2D sprites, DungeonMonsters2D): Spawns a group of wandering 2D monsters. Uses PascalCase animator states (`Idle`, `Move`) and triggers (`AttackTrigger`, `SpecialATrigger`). Flips sprite X scale for facing direction. Best for DungeonMonsters2D prefabs.
|
||||
|
||||
**AnimalEffect** (3D models, Animal Pack Deluxe): Spawns a group of wandering 3D animals. Uses lowercase animator states (`idle`, `walk`, `eat`, `attack`) with `Animator.Play()`. Rotates model on Y axis for facing. Sets `sortingOrder=103`, `renderQueue=4000`, and `ZTest=Always` to render above the map. Best for Animal Pack Deluxe prefabs.
|
||||
|
||||
**DragonEffect** (single animated 3D creature): Spawns a 3D dragon that alternates between flying (circling at altitude with fly actions) and grounded (wandering with ground actions) modes. Uses `Animator.Play()` with DragonMapAnims controller. Best for large creatures with flight capabilities.
|
||||
|
||||
**Particle-based** (like `BeastsEffect.cs`): Good for simple effects like swarms or atmospheric particles.
|
||||
|
||||
To create a new effect script, use `[RequireComponent(typeof(RectTransform))]` for UI canvas positioning and accept tuning parameters as public fields.
|
||||
|
||||
### 2. Create the prefab
|
||||
|
||||
1. Create an empty GameObject in `Assets/Eagle/Effects/`
|
||||
2. Add a `RectTransform` component
|
||||
3. Add your effect MonoBehaviour
|
||||
4. Wire up any references (animated prefab, textures, materials)
|
||||
5. Save as prefab
|
||||
|
||||
### 3. Register in ProvinceBeastsController
|
||||
|
||||
Add a prefab field and a case in `GetEffectPrefab()`:
|
||||
|
||||
```csharp
|
||||
// In ProvinceBeastsController.cs
|
||||
|
||||
// Add inspector field alongside existing ones:
|
||||
public GameObject wolfEffectPrefab;
|
||||
|
||||
// Add case in GetEffectPrefab(), keyed by singularName from beasts.tsv:
|
||||
private GameObject GetEffectPrefab(string beastName) {
|
||||
switch (beastName) {
|
||||
case "dragon": return dragonEffectPrefab;
|
||||
case "wolf": return wolfEffectPrefab;
|
||||
default: return beastsEffectPrefab;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Wire up in the scene
|
||||
|
||||
In `Gameplay.unity`, find the `ProvinceBeastsController` component and assign your new prefab to the new field.
|
||||
|
||||
### 5. Test
|
||||
|
||||
Use `[ContextMenu]` methods on `ProvinceBeastsController` to test:
|
||||
- "Test Dragon in Motcia (ID 2)" spawns a dragon effect
|
||||
- Add similar methods for your beast type
|
||||
- "Clear All Effects" removes all active effects
|
||||
|
||||
## Beast name matching
|
||||
|
||||
Beast names come from `BeastInfo.SingularName` in the proto, which uses the canonical `singularName` from `src/main/resources/net/eagle0/eagle/beasts.tsv`. The switch in `GetEffectPrefab` uses exact string matching on this canonical name (e.g. `"dragon"`, `"wolf"`, `"skeleton"`).
|
||||
|
||||
Human-type beasts (pirates, bandits, etc.) are matched via a `HumanBeastNames` HashSet in `ProvinceBeastsController` rather than individual switch cases.
|
||||
|
||||
## Beast animation coverage
|
||||
|
||||
### Custom animations
|
||||
|
||||
| Beast | Effect Type | Asset Pack | Prefabs Used |
|
||||
|---|---|---|---|
|
||||
| dragon | DragonEffect | 3dFoin Dragon | dragon_skin.FBX (w/ DragonMapAnims controller) |
|
||||
| skeleton | MonsterEffect | DungeonMonsters2D | SkeletonWarrior, SkeletonArcher, SkeletonMage |
|
||||
| zombie | MonsterEffect | DungeonMonsters2D | Zombie, ZombieWarrior |
|
||||
| rat | MonsterEffect + AnimalEffect | DungeonMonsters2D + Animal Pack Deluxe | Rat (2D), Rat (3D) |
|
||||
| spider | MonsterEffect | DungeonMonsters2D | Spider |
|
||||
| demon | MonsterEffect | DungeonMonsters2D | Demon, Succubus |
|
||||
| orc | MonsterEffect | DungeonMonsters2D | Imp |
|
||||
| vampire | MonsterEffect | DungeonMonsters2D | Vampire |
|
||||
| knight types (6 names) | AnimalEffect | Polytope Studio | PT_Male_Knight_01/02, PT_Female_Knight_01/02 (w/ HumanMapAnims controller) |
|
||||
| soldier types (8 names) | AnimalEffect | Polytope Studio | PT_Male_Soldier_01/02, PT_Female_Soldier_01/02 (w/ HumanMapAnims controller) |
|
||||
| archer types (3 names) | AnimalEffect | Polytope Studio | PT_Male_Archer_01/02, PT_Female_Archer_01/02 (w/ HumanMapAnims controller) |
|
||||
| militia types (8 names) | AnimalEffect | Polytope Studio | PT_Male_Militia_01/02, PT_Female_Militia_01/02 (w/ HumanMapAnims controller) |
|
||||
| peasant types (9 names) | AnimalEffect | Polytope Studio | PT_Male_Peasant_01, PT_Female_Peasant_01_a (w/ HumanMapAnims controller) |
|
||||
| clown | AnimalEffect | Clown Pack | Clown, Fat Clown (w/ per-prefab controller overrides) |
|
||||
| giant | AnimalEffect | Polytope Studio | PT_Male/Female_Peasant at 3x scale (w/ HumanMapAnims controller) |
|
||||
| ogre, troll | AnimalEffect | Orc-Ogre Pack | OrcOgre_Animated (w/ OgreMapAnims controller) |
|
||||
| bear | AnimalEffect | Animal Pack Deluxe | Brown_bear |
|
||||
| boar | AnimalEffect | Animal Pack Deluxe | Wild_boar |
|
||||
| crocodile | AnimalEffect | Animal Pack Deluxe | Crocodile |
|
||||
| snake | AnimalEffect | Animal Pack Deluxe | Viper |
|
||||
| crab | AnimalEffect | Animal Pack Deluxe | Crab |
|
||||
| frog | AnimalEffect | Animal Pack Deluxe | Common_frog, Common_frog_v2, Common_frog_v3 |
|
||||
| rabbit | AnimalEffect | Animal Pack Deluxe | Wild_rabbit, Wild_rabbit_v2 |
|
||||
| salamander | AnimalEffect | Animal Pack Deluxe | Fire_salamander |
|
||||
| scorpion | AnimalEffect | Animal Pack Deluxe | Scorpion, Yellow_fattail_scorpion |
|
||||
| stag | AnimalEffect | Animal Pack Deluxe | Deer, Deer_male |
|
||||
| wild goat | AnimalEffect | Animal Pack Deluxe | Goat, Ibex |
|
||||
| wild pig | AnimalEffect | Animal Pack Deluxe | Iron_age_pig, Iron_age_pig_v2 |
|
||||
| wolf, wolverine | AnimalEffect | Animal Pack Deluxe | Wolf |
|
||||
| honey badger | AnimalEffect | HoneyBadger (Sketchfab) | HoneyBadger (w/ HoneyBadgerMapAnims controller) |
|
||||
| raccoon | AnimalEffect | Raccoon (Sketchfab) | Raccoon (w/ RaccoonMapAnims controller) |
|
||||
| tiger | AnimalEffect | ithappy Animals FREE | Tiger_001 (w/ TigerMapAnims controller) |
|
||||
| elephant | AnimalEffect | Animal Pack Deluxe v2 | African_elephant, Indian_elephant (w/ ElephantMapAnims controller) |
|
||||
| mammoth | AnimalEffect | Animal Pack Deluxe v2 | African_elephant, Indian_elephant (w/ ElephantMapAnims controller) |
|
||||
| hippopotamus | AnimalEffect | Africa Animals Pack Low Poly V2 | Hippopotamus (w/ HippopotamusMapAnims controller) |
|
||||
| lion | AnimalEffect | Africa Animals Pack Low Poly V1 | Lion, Lioness (w/ LionMapAnims controller) |
|
||||
| velociraptor | AnimalEffect | Dino Pack Low Poly V1 | VelociraptorColor1, VelociraptorColor2 (w/ VelociraptorMapAnims controller) |
|
||||
| black panther | AnimalEffect | Africa Animals Pack Low Poly V1 | BlackPanther (w/ BlackPantherMapAnims controller) |
|
||||
| rhinoceros | AnimalEffect | Africa Animals Pack Low Poly V1 | Rhinoceros (w/ RhinocerosMapAnims controller) |
|
||||
| gorilla | AnimalEffect | Africa Animals Pack Low Poly V2 | Gorilla (w/ GorillaMapAnims controller) |
|
||||
| hyena | AnimalEffect | Africa Animals Pack Low Poly V2 | Hyena (w/ HyenaMapAnims controller) |
|
||||
| leopard | AnimalEffect | Africa Animals Pack Low Poly V2 | Leopard (w/ LeopardMapAnims controller) |
|
||||
| warthog | AnimalEffect | Africa Animals Pack Low Poly V2 | Phacochoerus (w/ WarthogMapAnims controller) |
|
||||
| emu | AnimalEffect | Australia Animals Pack V1 | Emu (w/ EmuMapAnims controller) |
|
||||
| kangaroo | AnimalEffect | Australia Animals Pack V1 | Kangaroo (w/ KangarooMapAnims controller) |
|
||||
| tasmanian devil | AnimalEffect | Australia Animals Pack V1 | TasmanianDevil (w/ TasmanianDevilMapAnims controller) |
|
||||
| hippogryph | DragonEffect | Heroic Fantasy Creatures Full Pack Vol 2 | Hippogriff_PBR (w/ HippogryphMapAnims controller) |
|
||||
|
||||
### Human-type beasts
|
||||
|
||||
34 human-type beast names use 3D Polytope Studio characters grouped into 5 archetypes. Each archetype uses a shared `HumanMapAnims.controller` that retargets Clown animation clips (idle, walk, attack, damage) via Unity's Humanoid system. One additional human type (psychopath) uses the 2D Butcher sprite from DungeonMonsters2D.
|
||||
|
||||
| Archetype | Polytope Type | Beast Names (count) |
|
||||
|---|---|---|
|
||||
| **Knight** (heavy armor) | Knight M/F x2 | cultist, heretic, cannibal, terrorist, cossack, desperado (6) |
|
||||
| **Soldier** (medium armor) | Soldier M/F x2 | bandit, brigand, robber, highwayman, marauder, dacoit, freebooter, mobster (8) |
|
||||
| **Archer** (light armor, ranged) | Archer M/F x2 | thief, pirate, buccaneer (3) |
|
||||
| **Militia** (light armed) | Militia M/F x2 | hooligan, street tough, ruffian, scalawag, ne'er-do-well, crook, miscreant, asshole (8) |
|
||||
| **Peasant** (unarmed) | Peasant M/F | agitator, instigator, rabble-rouser, separatist, particularist, nihilist, proud boy, rebel, traitor (9) |
|
||||
|
||||
Asset packs: Polytope Studio - Lowpoly Medieval Characters (Soldiers, Knights, Militia, Archers, Peasants)
|
||||
|
||||
| **Butcher** (armed loner) | DungeonMonsters2D Butcher | psychopath (1) |
|
||||
|
||||
### No custom animation (falls back to generic vultures)
|
||||
|
||||
Ordered by spawn likelihood (most common first):
|
||||
|
||||
| Beast | Likelihood | Notes |
|
||||
|---|---|---|
|
||||
| chimpanzee | 0.04 | Primate |
|
||||
| unknown | 0.00 | Intentional fallback |
|
||||
|
||||
## Using low-poly animal packs (Africa Animals, Dino Pack)
|
||||
|
||||
Assets from these packs (by the same author) require extra setup because they ship with **Legacy** animation import and include physics components that conflict with AnimalEffect. The workflow below applies to all of them:
|
||||
- Africa Animals Pack Low Poly V1 (Lion, Lioness, Black Panther, Crocodile, Elephant, Giraffe, Rhinoceros, Tiger, Zebra)
|
||||
- Africa Animals Pack Low Poly V2 (Hippopotamus, Antelope, Gorilla, Hyena, Leopard, Phacochoerus/Warthog)
|
||||
- Dino Pack Low Poly V1 (Velociraptor, Brontosaurus, Pteranodon, Stegosaurus, T-Rex, Triceratops)
|
||||
- Australia Animals Pack V1 (Chlamydosaurus/Frilled Lizard, Echidna, Emu, Kangaroo, Koala, Platypus, Tasmanian Devil)
|
||||
|
||||
### Per-animal setup steps
|
||||
|
||||
1. **Switch FBX import to Generic animation type.**
|
||||
In the `.fbx.meta` file (location varies by pack):
|
||||
- Change `animationType: 1` to `animationType: 2`
|
||||
- Change `avatarSetup: 0` to `avatarSetup: 1` (newer format) — older metas without `avatarSetup` will auto-create an avatar at runtime
|
||||
- Set `loopTime: 1` on looping clips (idle, walk, eat, run) but NOT one-shot clips (attack, death, hurt)
|
||||
Unity will re-import the FBX with Mecanim-compatible clips when it next opens.
|
||||
|
||||
2. **Create an AnimatorController** in `Assets/Eagle/Effects/` named `<Animal>MapAnims.controller`.
|
||||
Use the same 4-state pattern (idle, walk, eat, attack) as ElephantMapAnims. Reference the animation clips by their fileIDs from the FBX meta's `internalIDToNameTable` (type 74 entries) or `fileIDToRecycleName` (older format, type 7400000+). These IDs are stable across import type changes. Note: if the FBX names its idle clip `idle1`, create the controller state as `idle` but reference the `idle1` clip by fileID.
|
||||
|
||||
3. **Create an AnimalEffect prefab** in `Assets/Eagle/Effects/` named `<Animal>Effect.prefab`.
|
||||
Reference the pack's prefab as the animal prefab, and wire up the new AnimatorController. Multiple color/sex variants from the same or similar FBX can share one controller (e.g., Lion and Lioness). AnimalEffect will automatically strip the pack's legacy Animation, Rigidbody, and Collider components at spawn time, and add an Animator if the prefab doesn't already have one.
|
||||
|
||||
4. **Register in ProvinceBeastsController** with a case in `GetEffectPrefab()` and a test ContextMenu method.
|
||||
|
||||
5. **Mark the effect prefab as Addressable** with the `beast-effects` label in the Unity editor (Window > Asset Management > Addressables > Groups).
|
||||
|
||||
### Notes
|
||||
|
||||
- The packs include both SingleTexture and TextureAtlas variants; use SingleTexture for best visual quality.
|
||||
- Hippos have water-specific animations (idlewater, deathwater) that could be used for future water-province effects.
|
||||
- The packs' prefabs include Rigidbody/BoxCollider components intended for gameplay use. AnimalEffect strips these automatically since it controls position directly.
|
||||
|
||||
### Available animals not yet set up as beasts
|
||||
|
||||
These animals exist in the imported packs and could be set up as in-game beasts using the workflow above:
|
||||
|
||||
**Africa Animals Pack V1** (already imported):
|
||||
| Animal | Good fit for | Notes |
|
||||
|---|---|---|
|
||||
| Giraffe (x2 variants) | giraffe beast | Tall model, may need larger scale |
|
||||
| Zebra | zebra beast | Herd animal; good with higher animalCount |
|
||||
|
||||
(Black Panther, Rhinoceros, Crocodile, Elephant, and Tiger already have effects.)
|
||||
|
||||
**Africa Animals Pack V2** (already imported):
|
||||
| Animal | Good fit for | Notes |
|
||||
|---|---|---|
|
||||
| Antelope | antelope/gazelle beast | Fast-moving; good with higher moveSpeed |
|
||||
|
||||
(Gorilla, Hyena, Leopard, Warthog, and Hippopotamus already have effects.)
|
||||
|
||||
**Dino Pack Low Poly V1** (already imported):
|
||||
| Animal | Good fit for | Notes |
|
||||
|---|---|---|
|
||||
| Brontosaurus | brontosaurus/sauropod beast | Very large; needs big scale, low count |
|
||||
| Pteranodon | pteranodon/flying beast | Has fly animation; could use DragonEffect-style flight |
|
||||
| Stegosaurus | stegosaurus beast | Large herbivore |
|
||||
| T-Rex | tyrannosaurus beast | Iconic predator; good as a solo beast |
|
||||
| Triceratops | triceratops beast | Large herbivore; good for tough beast |
|
||||
|
||||
(Velociraptor already has an effect.)
|
||||
|
||||
**Australia Animals Pack V1** (already imported):
|
||||
| Animal | Good fit for | Notes |
|
||||
|---|---|---|
|
||||
| Koala | koala beast | Peaceful; good for low-threat province events |
|
||||
| Echidna | echidna beast | Small, spiny |
|
||||
| Chlamydosaurus (Frilled Lizard) | lizard beast | Has threat display animation |
|
||||
| Platypus (Ornitorinco) | platypus beast | Semi-aquatic; unique |
|
||||
|
||||
(Emu, Kangaroo, and Tasmanian Devil already have effects.)
|
||||
|
||||
None of the remaining animals above are currently in `beasts.tsv`.
|
||||
|
||||
## Tips
|
||||
|
||||
- Keep effects lightweight; multiple provinces may have beasts simultaneously
|
||||
- Use `ParticleSystemScalingMode.Hierarchy` so effects scale with the map zoom
|
||||
- Set appropriate `sortingOrder` on renderers (existing effects use 102-104)
|
||||
- For animated prefabs, set `AnimatorCullingMode.AlwaysAnimate` since UI elements may be considered offscreen by the culling system
|
||||
- For 3D models (AnimalEffect), set `renderQueue=4000` and `ZTest=Always` on materials to prevent clipping behind the map
|
||||
@@ -1,280 +0,0 @@
|
||||
# CommandProto Usage Analysis in shardok/ai
|
||||
|
||||
This document analyzes all remaining usages of `CommandProto` (protocol buffer representation) in the AI code and identifies opportunities to eliminate proto conversion by using `ShardokCommand` directly.
|
||||
|
||||
## Summary
|
||||
|
||||
**Total CommandProto usages found:** 42 locations across 9 files
|
||||
|
||||
**Eliminated:** 6 usages (14%) - ✅ **Phase 1 Complete**
|
||||
**Can be eliminated:** ~14 usages (33%)
|
||||
**Must keep (for now):** ~22 usages (53%)
|
||||
|
||||
---
|
||||
|
||||
## Files with CommandProto Usage
|
||||
|
||||
### 1. AICommandFilter.cpp (6 usages) - ✅ **COMPLETED** (PR #4505)
|
||||
**Location:** Lines 146, 189, 252, 356, 387, 428
|
||||
|
||||
**Original usage:**
|
||||
```cpp
|
||||
const auto cmdProto = cmd.GetCommandProto();
|
||||
if (!cmdProto.has_target()) { ... }
|
||||
const auto& targetCoords = cmdProto.target();
|
||||
if (!cmdProto.has_actor()) { ... }
|
||||
const auto unitId = cmdProto.actor().value();
|
||||
```
|
||||
|
||||
**Replaced with:**
|
||||
```cpp
|
||||
const int targetRow = cmd.GetTargetRow();
|
||||
const int targetCol = cmd.GetTargetColumn();
|
||||
if (targetRow < 0 || targetCol < 0) {
|
||||
throw ShardokInternalErrorException("Command missing required target");
|
||||
}
|
||||
const Coords targetCoords(targetRow, targetCol);
|
||||
|
||||
const int actorId = cmd.GetActorUnitId();
|
||||
if (actorId < 0) {
|
||||
throw ShardokInternalErrorException("Command missing required actor");
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** ✅ **ELIMINATED** - Replaced with direct accessors + exception handling
|
||||
**Impact:** Eliminated 6 proto conversions in hot path (command filtering)
|
||||
**Completed:** Phase 1, PR #4505
|
||||
|
||||
---
|
||||
|
||||
### 2. ShardokAIClient.cpp (8 usages)
|
||||
**Location:** Lines 83, 86, 87, 102, 105, 237, 261, 311, 356
|
||||
|
||||
**Usage breakdown:**
|
||||
|
||||
#### a) Command validation (lines 83-87)
|
||||
```cpp
|
||||
void CheckCommand(const CommandProto &realDescriptor, const CommandProto &guessedDescriptor) {
|
||||
differencer.IgnoreField(CommandProto::descriptor()->FindFieldByNumber(
|
||||
CommandProto::kFollowUpCommandTypesFieldNumber));
|
||||
```
|
||||
**Status:** ❌ **MUST KEEP** - Uses protobuf reflection for comparison
|
||||
**Reason:** Comparing proto messages for correctness checking requires proto API
|
||||
|
||||
#### b) GetAvailableCommandProtos calls (lines 105, 356)
|
||||
```cpp
|
||||
const auto guessedCommands = guessedEngine.GetAvailableCommandProtos(playerId, false);
|
||||
if (const auto &availableCommands = engine.GetAvailableCommandProtos(playerId, false);
|
||||
```
|
||||
**Status:** ✅ **CAN REPLACE** - Should use `GetAvailableCommandsForAIPlayer()` instead
|
||||
**Impact:** This is a major conversion point - converts entire command list to protos
|
||||
**Priority:** HIGH (converts all commands to proto unnecessarily)
|
||||
|
||||
#### c) Strategy selector methods (lines 102, 237, 261, 311)
|
||||
```cpp
|
||||
const vector<CommandProto> &realAvailableCommands) const -> CommandChoiceResults
|
||||
```
|
||||
**Status:** ✅ **CAN REPLACE** - Depends on fixing strategy selector signatures
|
||||
**Priority:** MEDIUM (depends on other refactors)
|
||||
|
||||
---
|
||||
|
||||
### 3. IterativeDeepeningAI.cpp/hpp (4 usages)
|
||||
**Location:** Lines 41, 272 (cpp), 73, 96 (hpp)
|
||||
|
||||
**Current usage:**
|
||||
```cpp
|
||||
const std::vector<CommandProto>& commands,
|
||||
```
|
||||
|
||||
**Status:** ✅ **CAN REPLACE** - These methods should accept `CommandListSPtr` instead
|
||||
**Impact:** Major - this is the main AI search algorithm
|
||||
**Priority:** HIGH (core AI algorithm)
|
||||
|
||||
**Note:** IterativeDeepeningAI already receives commands as proto vectors. The conversion happens upstream at the entry point. Need to trace back to find where `GetAvailableCommandProtos` is called.
|
||||
|
||||
---
|
||||
|
||||
### 4. AIFleeDecisionCalculator.cpp/hpp (6 usages)
|
||||
**Location:** Lines 17, 38, 39, 62, 63 (hpp), 18, 19, 137, 138 (cpp)
|
||||
|
||||
**Current usage:**
|
||||
```cpp
|
||||
const vector<CommandProto>& availableCommands,
|
||||
const vector<CommandProto>::const_iterator& fleeCommand,
|
||||
```
|
||||
|
||||
**Status:** ✅ **CAN REPLACE** - Should use `CommandListSPtr` and indices instead
|
||||
**Impact:** Flee decision logic could avoid proto conversion
|
||||
**Priority:** MEDIUM
|
||||
|
||||
---
|
||||
|
||||
### 5. AIAttackerStrategySelector.cpp/hpp (2 usages)
|
||||
**Location:** Line 30 in both files
|
||||
|
||||
**Current usage:**
|
||||
```cpp
|
||||
const vector<CommandProto>& availableCommands) -> AIStrategy
|
||||
```
|
||||
|
||||
**Status:** ⚠️ **PARTIALLY REPLACEABLE** - Currently doesn't use the commands parameter
|
||||
**Current implementation:**
|
||||
```cpp
|
||||
const vector<CommandProto>& /*availableCommands*/) -> AIStrategy {
|
||||
// Parameter is commented out - not used!
|
||||
return AIStrategy::DEFAULT;
|
||||
}
|
||||
```
|
||||
**Priority:** LOW (parameter unused, but signature should be consistent)
|
||||
|
||||
---
|
||||
|
||||
### 6. AICommandEvaluator.hpp (1 usage)
|
||||
**Location:** Line 27
|
||||
|
||||
**Current usage:**
|
||||
```cpp
|
||||
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
|
||||
```
|
||||
|
||||
**Status:** ⚠️ **CHECK USAGE** - Type alias, need to check if used
|
||||
**Priority:** LOW (just a type alias)
|
||||
|
||||
---
|
||||
|
||||
### 7. AIScoreCalculator.hpp (1 usage)
|
||||
**Location:** Line 24
|
||||
|
||||
**Current usage:**
|
||||
```cpp
|
||||
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
|
||||
```
|
||||
|
||||
**Status:** ⚠️ **CHECK USAGE** - Type alias, need to check if used
|
||||
**Priority:** LOW (just a type alias)
|
||||
|
||||
---
|
||||
|
||||
### 8. AIWaterCrossingCommandChooser.hpp (1 usage)
|
||||
**Location:** Line 20
|
||||
|
||||
**Current usage:**
|
||||
```cpp
|
||||
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
|
||||
```
|
||||
|
||||
**Status:** ⚠️ **CHECK USAGE** - Type alias, need to check if used
|
||||
**Priority:** LOW (just a type alias)
|
||||
|
||||
---
|
||||
|
||||
## Key Conversion Points (Entry Points)
|
||||
|
||||
### ShardokEngine::GetAvailableCommandProtos()
|
||||
This method converts the entire command list from `CommandListSPtr` to `vector<CommandProto>`.
|
||||
|
||||
**Current flow:**
|
||||
```
|
||||
ShardokEngine::GetAvailableCommandsForAIPlayer() → CommandListSPtr
|
||||
↓ (conversion)
|
||||
ShardokEngine::GetAvailableCommandProtos() → vector<CommandProto>
|
||||
↓
|
||||
AI algorithms (IterativeDeepeningAI, etc.)
|
||||
```
|
||||
|
||||
**Desired flow:**
|
||||
```
|
||||
ShardokEngine::GetAvailableCommandsForAIPlayer() → CommandListSPtr
|
||||
↓ (no conversion!)
|
||||
AI algorithms use CommandSPtr directly
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recommendations by Priority
|
||||
|
||||
### HIGH Priority (Performance-critical hot paths)
|
||||
|
||||
1. **AICommandFilter.cpp (6 usages)**
|
||||
- Replace `cmd.GetCommandProto()` with direct accessor methods
|
||||
- Use `GetActorUnitId()`, `GetTargetRow()`, `GetTargetColumn()`
|
||||
- Impact: Eliminates 6 proto conversions per filtered command
|
||||
|
||||
2. **ShardokAIClient.cpp - GetAvailableCommandProtos calls**
|
||||
- Replace calls to `GetAvailableCommandProtos()` with `GetAvailableCommandsForAIPlayer()`
|
||||
- Impact: Eliminates conversion of entire command list
|
||||
|
||||
3. **IterativeDeepeningAI**
|
||||
- Change signature from `vector<CommandProto>` to `CommandListSPtr`
|
||||
- Impact: Main AI search algorithm avoids proto conversion
|
||||
|
||||
### MEDIUM Priority
|
||||
|
||||
4. **AIFleeDecisionCalculator**
|
||||
- Change to use `CommandListSPtr` and indices
|
||||
- Impact: Flee decision logic avoids proto
|
||||
|
||||
5. **ShardokAIClient strategy methods**
|
||||
- Update signatures to use `CommandListSPtr`
|
||||
- Cascades to strategy selectors
|
||||
|
||||
### LOW Priority
|
||||
|
||||
6. **Type aliases**
|
||||
- Remove unused `using CommandProto` declarations
|
||||
- Clean up imports
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Low-hanging fruit (AICommandFilter) - ✅ **COMPLETED** (PR #4505)
|
||||
- ✅ Replaced 6 proto conversions with direct accessor calls
|
||||
- ✅ Added exception handling for missing actor/target data
|
||||
- ✅ No signature changes needed
|
||||
- ✅ Immediate performance benefit
|
||||
- **PR:** #4505
|
||||
|
||||
### Phase 2: Entry point (ShardokAIClient)
|
||||
- Replace `GetAvailableCommandProtos()` calls with `GetAvailableCommandsForAIPlayer()`
|
||||
- Update method signatures in ShardokAIClient
|
||||
|
||||
### Phase 3: Core AI (IterativeDeepeningAI)
|
||||
- Change IterativeDeepeningAI to accept `CommandListSPtr`
|
||||
- This is the biggest change but has highest impact
|
||||
|
||||
### Phase 4: Supporting systems
|
||||
- Update AIFleeDecisionCalculator
|
||||
- Update strategy selectors
|
||||
- Clean up type aliases
|
||||
|
||||
### Phase 5: Validation code
|
||||
- Keep proto-based validation as-is (uses reflection)
|
||||
- Consider if validation is still needed in production
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **MCTS already converted**: The MCTS code path already uses `CommandListSPtr` directly
|
||||
- **Proto still needed**: For serialization/network communication (not in AI hot path)
|
||||
- **Validation**: Proto comparison in CheckCommand() should remain (uses proto reflection)
|
||||
|
||||
---
|
||||
|
||||
## Estimated Impact
|
||||
|
||||
**Proto conversions eliminated:** ~20-25 per command choice
|
||||
**Performance gain:** Eliminates hundreds of allocations per AI decision
|
||||
**Code simplification:** Removes proto conversion layer from AI
|
||||
|
||||
**Before:**
|
||||
```
|
||||
Command → Proto → AI Decision
|
||||
```
|
||||
|
||||
**After:**
|
||||
```
|
||||
Command → AI Decision (direct)
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,159 +0,0 @@
|
||||
# Hetzner Setup Guide
|
||||
|
||||
This guide walks through setting up Hetzner Cloud infrastructure for running Shardok on-demand compute.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- All code PRs merged (#4990, #4996, #4998, #5001, #5009)
|
||||
- Access to DigitalOcean Container Registry (for pulling Shardok ARM64 image)
|
||||
|
||||
---
|
||||
|
||||
## Step 1: Create Hetzner Cloud Account
|
||||
|
||||
1. Go to https://console.hetzner.cloud/
|
||||
2. Sign up and add payment method
|
||||
3. Create a new project (e.g., "eagle0")
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Generate Hetzner API Token
|
||||
|
||||
1. In Hetzner Console → Security → API Tokens
|
||||
2. Click "Generate API Token"
|
||||
3. Give it **Read & Write** permissions
|
||||
4. Copy the token (you'll only see it once)
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Generate Shardok Auth Token
|
||||
|
||||
Generate a 256-bit random token for Eagle-Shardok authentication:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Save this output - it's the shared secret between Eagle and Shardok.
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Store Secrets in GitHub Actions
|
||||
|
||||
Add these secrets in GitHub → Settings → Secrets and variables → Actions:
|
||||
|
||||
| Secret Name | Description |
|
||||
|-------------|-------------|
|
||||
| `HETZNER_API_TOKEN` | From Step 2 - for Hetzner API calls |
|
||||
| `SHARDOK_AUTH_TOKEN` | From Step 3 - shared secret for gRPC auth |
|
||||
|
||||
Note: `DO_REGISTRY_TOKEN` already exists and will be used for Hetzner to pull container images.
|
||||
|
||||
These secrets will be passed to Eagle at runtime via `docker_build.yml`, similar to how `OPENAI_API_KEY` and other secrets are handled.
|
||||
|
||||
---
|
||||
|
||||
## Step 5: DNS Setup (for Let's Encrypt)
|
||||
|
||||
You need a domain pointing to the Shardok instance for TLS certificates.
|
||||
|
||||
### Option A: Floating IP (Recommended)
|
||||
|
||||
1. In Hetzner Console → Networking → Floating IPs
|
||||
2. Create a **Floating IPv6** in **Hillsboro, Oregon (hil)** region
|
||||
- IPv6 costs €1/month vs €3/month for IPv4
|
||||
- Hillsboro has better latency to DigitalOcean SFO than Ashburn
|
||||
- Server-to-server communication works fine with IPv6-only
|
||||
3. Point `shardok.prod.eagle0.net` to this IP via AAAA record
|
||||
4. The ShardokInstanceManager will attach this IP to instances on spin-up
|
||||
|
||||
**Location choice**: Hillsboro, OR (`hil`) is recommended for US West Coast. Same pricing as Ashburn (`ash`).
|
||||
|
||||
### Option B: Dynamic DNS
|
||||
|
||||
Update DNS programmatically when instance spins up. More complex but avoids floating IP cost.
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Upload SSH Key to Hetzner
|
||||
|
||||
For debugging access to instances:
|
||||
|
||||
1. In Hetzner Console → Security → SSH Keys
|
||||
2. Click "Add SSH Key"
|
||||
3. Paste your public key (e.g., `~/.ssh/id_rsa.pub`)
|
||||
4. Give it a name (e.g., "eagle-deploy")
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Wire Security Config into Eagle
|
||||
|
||||
Update Eagle's startup code to use the security config when connecting to remote Shardok:
|
||||
|
||||
```scala
|
||||
val securityConfig = ShardokSecurityConfig(
|
||||
useTls = true,
|
||||
authToken = Some(sys.env("SHARDOK_AUTH_TOKEN"))
|
||||
)
|
||||
|
||||
val channel = ServerSetupHelpers.newChannel(
|
||||
"shardok.prod.eagle0.net",
|
||||
50051,
|
||||
securityConfig
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Instance Spin-up
|
||||
|
||||
Test the Hetzner integration by triggering instance creation:
|
||||
|
||||
```scala
|
||||
val manager = new ShardokInstanceManager(
|
||||
hetznerApiToken = sys.env("HETZNER_API_TOKEN"),
|
||||
// ... other config
|
||||
)
|
||||
|
||||
manager.ensureInstanceRunning()
|
||||
```
|
||||
|
||||
### Verify TLS and Auth
|
||||
|
||||
1. Instance spins up and gets Let's Encrypt certificate
|
||||
2. Eagle connects via TLS
|
||||
3. Auth token is validated on each request
|
||||
|
||||
---
|
||||
|
||||
## Cost Estimate
|
||||
|
||||
| Component | Cost |
|
||||
|-----------|------|
|
||||
| CAX41 (16 ARM cores) | ~$0.04/hour |
|
||||
| Floating IP | ~$4/month |
|
||||
| Typical usage (20 hrs/week) | ~$3.50/month compute |
|
||||
|
||||
**Total: ~$7-8/month** for typical usage.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Instance won't start
|
||||
- Check Hetzner API token has Read & Write permissions
|
||||
- Verify you're using the correct region (`hil` for Hillsboro OR, or `ash` for Ashburn VA)
|
||||
|
||||
### TLS certificate fails
|
||||
- Ensure DNS points to the instance IP before certbot runs
|
||||
- Check port 80 is open for Let's Encrypt HTTP-01 challenge
|
||||
|
||||
### Auth failures
|
||||
- Verify `SHARDOK_AUTH_TOKEN` matches on both Eagle and Shardok
|
||||
- Check the token file is readable by Shardok container
|
||||
|
||||
### Can't pull container image
|
||||
- Ensure `DO_REGISTRY_TOKEN` is passed to cloud-init
|
||||
- Verify the ARM64 image exists: `registry.digitalocean.com/eagle0/shardok-server:arm64-latest`
|
||||
@@ -1,94 +0,0 @@
|
||||
# LLM Model Comparison
|
||||
|
||||
This document compares streaming latency (time-to-first-token) and pricing across OpenAI, Anthropic (Claude), and Google (Gemini) models for use in Eagle's narrative text generation.
|
||||
|
||||
## Test Methodology
|
||||
|
||||
All tests were performed locally using curl with streaming enabled. Each model was tested 3 times with the same prompt:
|
||||
|
||||
> "Write a short paragraph about a brave knight who discovers a hidden cave. Make it vivid and descriptive."
|
||||
|
||||
Time-to-first-token (TTFT) was measured from request initiation to the first text content appearing in the stream.
|
||||
|
||||
## Streaming Latency Results (January 2026)
|
||||
|
||||
| Model | Run 1 | Run 2 | Run 3 | Average TTFT |
|
||||
|-------|-------|-------|-------|--------------|
|
||||
| **Gemini 2.5 Flash-Lite** | 0.76s | 0.54s | 0.53s | **~0.6s** |
|
||||
| **gpt-4.1-mini** | 1.65s | 1.72s | 1.68s | **~1.7s** |
|
||||
| **claude-3-5-haiku** | 1.85s | 1.92s | 1.88s | **~1.9s** |
|
||||
| gpt-5.2 | 3.25s | 3.38s | 3.32s | **~3.3s** |
|
||||
| gpt-5-mini | 2.52s | 5.82s | 3.12s | **~3.8s** (high variance) |
|
||||
| Gemini 3 Flash Preview | 4.11s | 4.77s | 4.28s | **~4.4s** |
|
||||
| claude-sonnet-4 | 4.89s | 5.12s | 4.98s | **~5.0s** |
|
||||
| Gemini 2.5 Flash | 5.60s | 7.00s | 7.79s | **~6.8s** |
|
||||
|
||||
## Pricing Comparison (per 1M tokens)
|
||||
|
||||
| Model | Input Price | Output Price | Notes |
|
||||
|-------|-------------|--------------|-------|
|
||||
| **Gemini 2.5 Flash-Lite** | $0.10 | $0.40 | Cheapest and fastest |
|
||||
| Gemini 2.5 Flash | $0.15 | $0.60 | |
|
||||
| gpt-5-mini | $0.25 | $2.00 | |
|
||||
| **gpt-4.1-mini** | $0.40 | $1.60 | Best OpenAI value |
|
||||
| Gemini 3 Flash Preview | $0.50 | $3.00 | Includes thinking tokens |
|
||||
| **claude-3-5-haiku** | $0.80 | $4.00 | Best Anthropic value |
|
||||
| gpt-5.2 | ~$1.00 | ~$10.00 | Full reasoning model |
|
||||
| Gemini 2.5 Pro | $1.25 | $10.00 | |
|
||||
| Gemini 3 Pro Preview | $2.00 | $12.00 | ≤200K context |
|
||||
| claude-sonnet-4 | $3.00 | $15.00 | |
|
||||
|
||||
## Recommendations
|
||||
|
||||
### For Narrative Text Generation (Default)
|
||||
|
||||
**Gemini 2.5 Flash-Lite** is recommended as the default:
|
||||
- Fastest TTFT (~0.6s) - nearly 3x faster than alternatives
|
||||
- Cheapest pricing ($0.10/$0.40 per 1M tokens)
|
||||
- Quality is acceptable for short narrative snippets
|
||||
|
||||
### Alternative Options
|
||||
|
||||
| Priority | Model | When to Use |
|
||||
|----------|-------|-------------|
|
||||
| Speed + Cost | Gemini 2.5 Flash-Lite | Default for most use cases |
|
||||
| Speed + Quality | gpt-4.1-mini | When you need OpenAI quality with good speed |
|
||||
| Instruction Following | claude-3-5-haiku | Complex multi-step prompts, consistent tone |
|
||||
| Maximum Quality | claude-sonnet-4 or gpt-5.2 | When output quality is paramount |
|
||||
|
||||
### Quality Trade-offs
|
||||
|
||||
For short narrative snippets (1-3 paragraphs):
|
||||
- **Flash-Lite vs Haiku/4.1-mini**: Minor quality difference, significant speed gain
|
||||
- **Haiku vs Sonnet**: Noticeable quality difference in creative writing variety
|
||||
- **gpt-4.1-mini vs gpt-5.2**: Moderate quality difference, significant cost savings
|
||||
|
||||
## Configuration
|
||||
|
||||
LLM settings can be changed at runtime via the admin console:
|
||||
|
||||
1. Navigate to Admin Console → Settings
|
||||
2. Change `LlmProvider` to select vendor (gemini, openai, claude)
|
||||
3. Change the corresponding model name setting:
|
||||
- `GeminiModelName` (default: gemini-2.5-flash-lite)
|
||||
- `OpenAiModelName` (default: gpt-4.1-mini)
|
||||
- `ClaudeModelName` (default: claude-3-5-haiku-20241022)
|
||||
|
||||
Changes take effect on the next LLM request.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
For production deployment, ensure API keys are set:
|
||||
|
||||
```bash
|
||||
OPENAI_API_KEY=sk-...
|
||||
ANTHROPIC_API_KEY=sk-ant-...
|
||||
GEMINI_API_KEY=AIza...
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **gpt-5-mini** showed high latency variance (2.5s - 5.8s) in testing
|
||||
- **Gemini 2.5 Flash** was surprisingly slower than Flash-Lite, possibly due to internal reasoning overhead
|
||||
- **Gemini 3 Flash** is a frontier model with better quality but higher latency than 2.5 Flash-Lite
|
||||
- All Gemini models have a generous free tier (up to 1,000 daily requests)
|
||||
@@ -1,103 +0,0 @@
|
||||
# New Profession Proposals
|
||||
|
||||
This document proposes 5 new hero professions for Eagle0. Each profession has abilities for both the Eagle (strategic) and Shardok (tactical) game layers.
|
||||
|
||||
## Current Professions Reference
|
||||
|
||||
| Profession | Eagle Ability | Shardok Abilities |
|
||||
|------------|---------------|-------------------|
|
||||
| **Mage** | Control Weather | Lightning Bolt, Meteor, Freeze Water, Start Fire (enhanced) |
|
||||
| **Necromancer** | Start Epidemic | Raise Dead, Fear |
|
||||
| **Engineer** | (general) | Repair, Fortify, Build Bridge, Reduce (siege) |
|
||||
| **Paladin** | Alms (prioritized) | Holy Wave |
|
||||
| **Ranger** | Recon | Scout, Hide (enhanced), Brave Water (enhanced) |
|
||||
| **Champion** | (general) | Challenge Duel |
|
||||
|
||||
---
|
||||
|
||||
## Proposed New Professions
|
||||
|
||||
### 1. HERALD (Morale & Communication Specialist)
|
||||
|
||||
**Fantasy:** The inspiring leader who rallies troops and carries messages across the battlefield.
|
||||
|
||||
**Eagle Ability: "Rally Province"**
|
||||
- Spend vigor to boost recruitment in a province for one turn, or reduce unrest
|
||||
- Synergy: Pairs well with provinces in turmoil or after losses
|
||||
|
||||
**Shardok Ability: "Inspire"**
|
||||
- Target friendly unit within 3 hexes gains +1 action point this turn
|
||||
- Creates interesting tactical choices about when to act vs. when to buff allies
|
||||
- Cannot target self (prevents simple optimization)
|
||||
|
||||
---
|
||||
|
||||
### 2. ALCHEMIST (Fire & Transformation Specialist)
|
||||
|
||||
**Fantasy:** The mad scientist who manipulates the elements through science, not magic.
|
||||
|
||||
**Eagle Ability: "Transmute"**
|
||||
- Convert one resource type to another in a province (gold to food or vice versa, at a loss)
|
||||
- Provides economic flexibility during shortages
|
||||
|
||||
**Shardok Ability: "Wildfire"**
|
||||
- Start a fire that spreads to 2 additional adjacent hexes immediately (not just at end of round)
|
||||
- More aggressive than Mage's Start Fire but less controlled
|
||||
- Cannot freeze water (that's magic, not science)
|
||||
|
||||
---
|
||||
|
||||
### 3. WARDEN (Defensive Specialist)
|
||||
|
||||
**Fantasy:** The stalwart defender who holds the line and protects allies.
|
||||
|
||||
**Eagle Ability: "Garrison"**
|
||||
- A province with a Warden-led unit gets +1 to defense when attacked
|
||||
- Encourages strategic placement of defensive heroes
|
||||
|
||||
**Shardok Ability: "Intercept"**
|
||||
- Once per turn, when an adjacent friendly unit is attacked, the Warden's unit can take the hit instead
|
||||
- Creates a "bodyguard" mechanic that protects valuable units
|
||||
- Costs action points to maintain readiness
|
||||
|
||||
---
|
||||
|
||||
### 4. INQUISITOR (Anti-Magic & Intelligence)
|
||||
|
||||
**Fantasy:** The witch-hunter who counters supernatural threats and uncovers secrets.
|
||||
|
||||
**Eagle Ability: "Expose"**
|
||||
- Reveal hidden information about enemy heroes in a province (stats, profession, vigor)
|
||||
- Counter to Ranger's stealth/recon advantages
|
||||
|
||||
**Shardok Ability: "Dispel"**
|
||||
- Cancel an active magical effect: stop a meteor cast, remove Fear from friendly unit, or reveal a hidden unit
|
||||
- Direct counter to Mage and Necromancer abilities
|
||||
- Creates meaningful profession rock-paper-scissors
|
||||
|
||||
---
|
||||
|
||||
### 5. BEASTMASTER (Animal Control)
|
||||
|
||||
**Fantasy:** The wild one who commands beasts and understands nature's fury.
|
||||
|
||||
**Eagle Ability: "Suppress Beasts" (Enhanced)**
|
||||
- Already exists in game, but Beastmaster does it at reduced vigor cost
|
||||
- Additionally: Can redirect beast attacks to enemy provinces instead of just suppressing
|
||||
|
||||
**Shardok Ability: "Beast Call"**
|
||||
- Summon a wolf pack (weak undead-style unit) that attacks the nearest enemy
|
||||
- Wolves act immediately but disappear at end of round
|
||||
- Provides disposable units for screening or harassing archers
|
||||
|
||||
---
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
These professions were designed to:
|
||||
|
||||
1. **Fill mechanical gaps**: Warden provides defensive depth, Inquisitor counters magic-heavy strategies
|
||||
2. **Create counterplay**: Inquisitor vs Mage/Necromancer, Beastmaster vs Rangers (nature vs nature)
|
||||
3. **Avoid overlap**: Each has a unique niche not covered by existing professions
|
||||
4. **Support both layers**: Each ability is meaningful in its respective game mode
|
||||
5. **Enable interesting decisions**: Intercept creates bodyguard tactics, Inspire creates action economy choices
|
||||
@@ -1,150 +0,0 @@
|
||||
# Notification Diff Batching Optimization
|
||||
|
||||
## Problem
|
||||
|
||||
`ActionResultFilter.filterForPlayer` generates per-action-result game state diffs, which is expensive. Profiling shows significant time spent in `filteredGameStateDiff` → `GameStateViewFilter.filteredGameState` (called twice per result) → `GameStateViewDiffer.diff`.
|
||||
|
||||
A potential optimization is to batch diffs: instead of computing N diffs for N action results, compute one combined diff representing the final state change. However, this is blocked by how client notification generators work.
|
||||
|
||||
## Current Architecture
|
||||
|
||||
### Server Side
|
||||
1. `ActionResultFilter.filterForPlayer` processes each action result
|
||||
2. For each result, computes `filteredGameStateDiff(before, after, factionId)`
|
||||
3. Returns `Vector[ActionResultView]` where each view has its own `gameStateDiff`
|
||||
|
||||
### Client Side
|
||||
1. `EagleGameModel.HandleNewHistoryEntry` processes each `ActionResultView`:
|
||||
```csharp
|
||||
private void HandleNewHistoryEntry(ActionResultView arv) {
|
||||
_currentModel.HistoryCount++;
|
||||
MaybeSendNotification(arv); // Uses currentModel state
|
||||
ApplyGameStateViewDiff(arv.GameStateDiff); // Updates currentModel
|
||||
}
|
||||
```
|
||||
|
||||
2. Notification generators receive `(ActionResultView, IGameModel)` and look up display data from the model:
|
||||
```csharp
|
||||
var province = currentModel.Provinces[details.ProvinceId];
|
||||
var factionName = currentModel.FactionName(details.FactionId);
|
||||
var hero = currentModel.Heroes[heroId];
|
||||
var affectedProvinces = currentModel.ProvincesForFaction(factionId);
|
||||
```
|
||||
|
||||
### The Problem
|
||||
Notification for action N sees model state after actions 1..N-1 have been applied. If we batch diffs, notification N would see model state before ANY actions, potentially showing stale data.
|
||||
|
||||
Example:
|
||||
1. Action 1: Province X conquered by Faction B (was Faction A)
|
||||
2. Action 2: Notification needs to show Province X's current ruler
|
||||
|
||||
With batching, action 2's notification would incorrectly show Faction A.
|
||||
|
||||
## Audit of Client Notification Generators
|
||||
|
||||
~50 generators access `currentModel`. Key lookup patterns:
|
||||
|
||||
| Lookup | Count | Mutable? | Risk |
|
||||
|--------|-------|----------|------|
|
||||
| `currentModel.PlayerId` | 22 | No | Safe |
|
||||
| `currentModel.Provinces[id]` | 17 | Yes | `RulingFactionId` changes on conquest |
|
||||
| `currentModel.Heroes[id]` | ~20 | Mostly safe | Hero data stable, used for display |
|
||||
| `currentModel.FactionName(id)` | ~10 | No | Names don't change |
|
||||
| `currentModel.MaybeDestroyedFaction(id)` | ~15 | Yes | Faction may be destroyed |
|
||||
| `currentModel.ProvincesForFaction(id)` | ~15 | Yes | Changes on conquest |
|
||||
|
||||
### State-Changing Action Types
|
||||
- `ProvinceConquered` - changes province ownership
|
||||
- `FactionDestroyed` - removes faction
|
||||
- `FactionLeaderRemoved` - changes faction head
|
||||
|
||||
## Proposed Solution: Server-Side Display Data
|
||||
|
||||
Eliminate client model lookups by including all display data in server-generated notifications.
|
||||
|
||||
### Current Notification Structure
|
||||
```scala
|
||||
case class NotificationC(
|
||||
details: NotificationDetails,
|
||||
targetFactionIds: Vector[FactionId],
|
||||
affectedProvinceIds: Vector[ProvinceId], // Already exists, underused
|
||||
affectedHeroIds: Vector[HeroId], // Already exists, underused
|
||||
llm: NotificationT.Llm,
|
||||
deferred: Boolean
|
||||
)
|
||||
```
|
||||
|
||||
### Proposed Changes
|
||||
|
||||
**Option A: Add fields to NotificationC**
|
||||
```scala
|
||||
case class NotificationC(
|
||||
details: NotificationDetails,
|
||||
targetFactionIds: Vector[FactionId],
|
||||
affectedProvinceIds: Vector[ProvinceId],
|
||||
affectedHeroIds: Vector[HeroId],
|
||||
// New fields:
|
||||
factionNames: Map[FactionId, String],
|
||||
displayedHeroViews: Vector[HeroView],
|
||||
provinceNames: Map[ProvinceId, String],
|
||||
llm: NotificationT.Llm,
|
||||
deferred: Boolean
|
||||
)
|
||||
```
|
||||
|
||||
**Option B: Enrich each NotificationDetails type**
|
||||
```scala
|
||||
case class TruceAccepted(
|
||||
offeringFactionId: FactionId,
|
||||
offeringFactionName: String, // New
|
||||
targetFactionId: FactionId,
|
||||
targetFactionName: String, // New
|
||||
ambassadorHeroId: HeroId,
|
||||
ambassadorHeroView: HeroView // New
|
||||
)
|
||||
```
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. **Proto changes**: Add new fields to `Notification` message
|
||||
2. **Server**: Populate display fields when creating notifications
|
||||
3. **Client**: Update ~50 generators to use notification fields
|
||||
4. **Test**: Add test that greps for `currentModel.` access in generators (excluding `PlayerId`)
|
||||
5. **Server optimization**: With client decoupled from model state, batch diffs in `filterForPlayer`
|
||||
|
||||
### Trade-offs
|
||||
|
||||
**Pros:**
|
||||
- Clean separation: server provides all display data
|
||||
- Enables diff batching optimization
|
||||
- Easier to reason about notification correctness
|
||||
- Test can enforce the invariant
|
||||
|
||||
**Cons:**
|
||||
- Larger notification messages (includes names, hero views)
|
||||
- Proto changes required
|
||||
- ~50 generators need updating (mechanical but tedious)
|
||||
- Server must know what display data each notification type needs
|
||||
|
||||
## Alternative Approaches Considered
|
||||
|
||||
### A: Selective Per-Action Diffs
|
||||
Only generate individual diffs for action results with notifications that need mutable model state. Requires tracking which notification types need which state.
|
||||
|
||||
**Rejected because:** Fragile; easy to add a new generator that breaks the invariant.
|
||||
|
||||
### B: State-Change-Triggered Diffs
|
||||
If batch contains state-changing action types (ProvinceConquered, etc.), generate individual diffs from that point. Otherwise batch.
|
||||
|
||||
**Rejected because:** Still conservative; many batches would fall back to individual diffs.
|
||||
|
||||
## Status
|
||||
|
||||
**Deferred** - Current performance is acceptable. This doc captures the analysis for future reference if optimization becomes necessary.
|
||||
|
||||
## References
|
||||
|
||||
- `ActionResultFilter.scala` - Server-side filtering
|
||||
- `EagleGameModel.cs` - Client-side model updates
|
||||
- `Assets/Eagle/Notifications/` - All notification generators
|
||||
- `NotificationT.scala` - Server notification types
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,218 +0,0 @@
|
||||
# Programmatic Map Image Generation
|
||||
|
||||
## Overview
|
||||
|
||||
Create a tool to programmatically regenerate the Eagle strategic map images with more equalized province sizes while preserving neighbor relationships. Also implement dynamic text rendering for province names that scales with zoom.
|
||||
|
||||
## Current Map Assets
|
||||
|
||||
| File | Description | Dimensions |
|
||||
|------|-------------|------------|
|
||||
| `Assets/Eagle/rawGray.gz.bytes` | Province ID lookup map (gzip compressed) | 3786 x 1834 |
|
||||
| `Assets/Eagle/map_bw_labels.png` | B&W map with baked-in province labels | 3786 x 1834, RGBA |
|
||||
| `Assets/Eagle/Materials/1.png` - `43.png` | Individual province mask images | 3786 x 1834, grayscale |
|
||||
|
||||
## Province Data
|
||||
|
||||
- 43 provinces defined in `src/main/resources/net/eagle0/eagle/province_map.tsv`
|
||||
- Each has: id, name, neighbors, neighborPositions, hexMapName
|
||||
- Problem provinces (too small): **Shumal** (id=1), **Kojaria** (id=39), **Usvol** (id=33), **Tumala** (id=35), **Chia** (id=43)
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Equalize province sizes** - Reduce disparity so small provinces are easier to click
|
||||
2. **Preserve topology** - Maintain all neighbor relationships
|
||||
3. **Preserve coastline** - Keep ocean/land boundaries
|
||||
4. **Dynamic text rendering** - Province names scale with zoom level
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Province Size Equalization Tool
|
||||
|
||||
### Algorithm: Constrained Voronoi Relaxation
|
||||
|
||||
1. **Extract current state from rawGray:**
|
||||
- Calculate centroid of each province
|
||||
- Calculate area (pixel count) of each province
|
||||
- Identify ocean pixels (value 0 or 255)
|
||||
|
||||
2. **Iterative relaxation:**
|
||||
```
|
||||
For each iteration:
|
||||
For each province:
|
||||
Calculate target area (average of all provinces)
|
||||
If province is too small:
|
||||
Move centroid away from larger neighbors
|
||||
If province is too large:
|
||||
Move centroid toward smaller neighbors
|
||||
Constraint: Don't break neighbor relationships
|
||||
```
|
||||
|
||||
3. **Generate new boundaries:**
|
||||
- Use weighted Voronoi diagram from relaxed centroids
|
||||
- Weight by target area
|
||||
- Mask with original coastline
|
||||
|
||||
4. **Output new images:**
|
||||
- New rawGray (province ID map)
|
||||
- New province masks (1.png - 43.png)
|
||||
- New B&W base map (derived from boundaries)
|
||||
|
||||
### Implementation Options
|
||||
|
||||
**Option A: Python Script (Recommended)**
|
||||
- Use numpy, scipy, PIL/Pillow
|
||||
- Can run offline, generate assets once
|
||||
- Easier to iterate and debug
|
||||
- Output: New image files to replace existing
|
||||
|
||||
**Option B: Unity Editor Script**
|
||||
- C# with Unity's Texture2D
|
||||
- Integrated into build process
|
||||
- Slower, harder to debug
|
||||
|
||||
### Tool Location
|
||||
`tools/map_generator/` or `scripts/generate_map.py`
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Dynamic Province Name Rendering
|
||||
|
||||
### Current State
|
||||
- Province names are baked into `map_bw_labels.png`
|
||||
- Not visible when zoomed out, too small when zoomed in
|
||||
- `SetUpCenterText()` in MapController shows selected/hovered province name in a fixed UI panel
|
||||
|
||||
### New Approach
|
||||
|
||||
1. **Create province label GameObjects:**
|
||||
- One TextMeshPro label per province
|
||||
- Position at province centroid
|
||||
- Child of map content (moves with zoom/pan)
|
||||
|
||||
2. **Dynamic scaling:**
|
||||
- Scale inversely with zoom level
|
||||
- At zoom 1.0: Show major provinces only (or none)
|
||||
- At zoom 2.0+: Show all province names
|
||||
- Font size adjusts to fit province area
|
||||
|
||||
3. **Implementation:**
|
||||
```csharp
|
||||
public class ProvinceLabelsController : MonoBehaviour {
|
||||
public MapPinchZoomHandler zoomHandler;
|
||||
public TextMeshProUGUI labelPrefab;
|
||||
|
||||
private Dictionary<int, TextMeshProUGUI> _labels;
|
||||
|
||||
void Update() {
|
||||
float zoom = zoomHandler.CurrentZoom;
|
||||
foreach (var label in _labels.Values) {
|
||||
label.transform.localScale = Vector3.one / zoom;
|
||||
label.gameObject.SetActive(zoom >= 1.5f);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Province centroid data:**
|
||||
- Add `centroid_x`, `centroid_y` columns to `province_map.tsv`
|
||||
- Or calculate at runtime from rawGray
|
||||
|
||||
---
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
### New Files
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `tools/map_generator/generate_map.py` | Python script to generate new map images |
|
||||
| `tools/map_generator/requirements.txt` | Python dependencies |
|
||||
| `Assets/Eagle/ProvinceLabelsController.cs` | Dynamic province name rendering |
|
||||
|
||||
### Modified Files
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `Assets/Eagle/rawGray.gz.bytes` | Replaced with equalized version |
|
||||
| `Assets/Eagle/Materials/*.png` | Replaced province masks |
|
||||
| `Assets/Eagle/map_bw_labels.png` | Either: regenerated, or removed if using dynamic labels |
|
||||
| `province_map.tsv` | Add centroid columns |
|
||||
| `Assets/Gameplay.unity` | Add ProvinceLabelsController, label prefab instances |
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Visual inspection:**
|
||||
- Compare old vs new province sizes
|
||||
- Verify small provinces (Shumal, Kojaria, Usvol) are larger
|
||||
- Verify neighbor relationships preserved
|
||||
|
||||
2. **Functional testing:**
|
||||
- Click on each province at various zoom levels
|
||||
- Verify correct province is selected
|
||||
- Verify province colors/weather still work
|
||||
|
||||
3. **Label testing:**
|
||||
- Zoom in/out, verify labels scale appropriately
|
||||
- Pan around, verify labels move with map
|
||||
- Verify labels don't overlap excessively
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
1. **Equalization approach:** Reduce disparity only
|
||||
- Make small provinces (Shumal, Kojaria, Usvol, etc.) 2-3x larger
|
||||
- Minimal changes to large provinces
|
||||
- Not targeting perfectly equal areas
|
||||
|
||||
2. **Coastline:** Allow simplification
|
||||
- Coastline can be smoothed/simplified as part of generation
|
||||
- Voronoi edges are acceptable
|
||||
|
||||
3. **Map style:** Solid fills with borders
|
||||
- Clean, simple look
|
||||
- Easier to generate programmatically
|
||||
- Province boundaries rendered as dark lines
|
||||
|
||||
---
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Python Map Generator Tool
|
||||
|
||||
```
|
||||
tools/map_generator/
|
||||
├── generate_map.py # Main script
|
||||
├── requirements.txt # numpy, scipy, Pillow
|
||||
└── README.md # Usage instructions
|
||||
```
|
||||
|
||||
**Algorithm:**
|
||||
1. Load current rawGray.gz.bytes, decompress
|
||||
2. Calculate each province's centroid and area
|
||||
3. Identify land mask (non-ocean pixels)
|
||||
4. Run constrained Voronoi relaxation:
|
||||
- Small provinces push neighbors away
|
||||
- Large provinces pull neighbors in
|
||||
- Stop when min province is >= 50% of average
|
||||
5. Generate weighted Voronoi from relaxed centroids
|
||||
6. Output:
|
||||
- `rawGray_new.bytes` (province ID map)
|
||||
- `1.png` - `43.png` (province masks)
|
||||
- `map_bw.png` (solid fill + borders)
|
||||
- `centroids.json` (for label positioning)
|
||||
|
||||
### Step 2: Dynamic Province Labels (Unity)
|
||||
|
||||
1. Create `ProvinceLabelsController.cs`
|
||||
2. Load centroids from JSON or calculate from rawGray
|
||||
3. Spawn TextMeshPro labels at each centroid
|
||||
4. Update label scale/visibility based on zoom
|
||||
|
||||
### Step 3: Integration
|
||||
|
||||
1. Replace asset files with generated versions
|
||||
2. Update `MapController` to use new assets
|
||||
3. Add `ProvinceLabelsController` to scene
|
||||
4. Remove baked label image dependency
|
||||
@@ -1,79 +0,0 @@
|
||||
# Renaming a Province
|
||||
|
||||
This document explains the steps required to rename a province in Eagle0.
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### 1. Province Map TSV (Server Source of Truth)
|
||||
|
||||
**File:** `src/main/resources/net/eagle0/eagle/province_map.tsv`
|
||||
|
||||
This is the primary source of province data. Each row contains:
|
||||
- Province ID
|
||||
- Province name
|
||||
- Neighbor IDs
|
||||
- Neighbor directions
|
||||
- Province name (repeated)
|
||||
- Neighbor names (dot-separated)
|
||||
- Starting food
|
||||
|
||||
You need to:
|
||||
1. Change the province name in its own row (columns 2 and 5)
|
||||
2. Update the neighbor name references in all neighboring provinces (column 6)
|
||||
|
||||
Example: To rename "Fluria" to "NewName", you would need to update:
|
||||
- Line 26: The Fluria row itself
|
||||
- Lines 5, 10, 18, 24, 27, 38, 41: All provinces that list Fluria as a neighbor
|
||||
|
||||
### 2. LLM Map Description
|
||||
|
||||
**File:** `src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators/MapDescription.scala`
|
||||
|
||||
This file contains a hardcoded geographic description of the map used in LLM prompts for generating narrative text. Update any references to the old province name.
|
||||
|
||||
### 3. Client Centroids JSON
|
||||
|
||||
**File:** `src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/centroids.json`
|
||||
|
||||
This JSON file contains province metadata used for map rendering (centroid positions, areas, orientations). Each entry has a `name` field that should be updated to match.
|
||||
|
||||
Note: The actual province name displayed to players comes from the server via the `ProvinceView` proto, not from this file. However, this file should be kept in sync for consistency and because it may be used for map label positioning.
|
||||
|
||||
### 4. Test Files
|
||||
|
||||
**File:** `src/test/scala/net/eagle0/eagle/library/util/validations/RuntimeValidatorTest.scala`
|
||||
|
||||
Update any test strings that reference the old province name.
|
||||
|
||||
## How Province Names Flow to the Client
|
||||
|
||||
Province names are sent from the Eagle server to the Unity client via the `ProvinceView` protobuf message:
|
||||
|
||||
```protobuf
|
||||
message ProvinceView {
|
||||
int32 id = 1;
|
||||
string name = 6; // <-- Province name sent from server
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The server reads province names from `province_map.tsv` at startup. The client receives names through the proto and displays them via `province.Name` in C# code. This means:
|
||||
|
||||
- Changing the TSV automatically updates what clients see
|
||||
- No C# code changes are needed (the code uses `province.Name` generically)
|
||||
- The `centroids.json` name field is for map rendering/positioning, not display
|
||||
|
||||
## Files That Do NOT Need Changes
|
||||
|
||||
- **Hero backstory TSV files** (`heroes.tsv`, `generated_heroes.tsv`) - These don't contain province name references
|
||||
- **C# client code** - Uses `province.Name` from the server proto, not hardcoded names
|
||||
- **Proto definitions** - No province names are hardcoded in protos
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Update `province_map.tsv` - province's own row
|
||||
- [ ] Update `province_map.tsv` - all neighbor references
|
||||
- [ ] Update `MapDescription.scala`
|
||||
- [ ] Update `centroids.json`
|
||||
- [ ] Update any test files with province name references
|
||||
- [ ] Build and run tests: `bazel test //src/test/scala/...`
|
||||
@@ -1,150 +0,0 @@
|
||||
# rules_apple Workspace Separation
|
||||
|
||||
This document explains why `rules_apple` is built in a separate Bazel workspace (`sparkle_workspace/`) and what conditions need to be met before it can be reintegrated into the main workspace.
|
||||
|
||||
## Background
|
||||
|
||||
The main workspace cannot include `rules_apple` due to transitive dependency conflicts with `rules_swift`. Multiple dependencies require different major versions of `rules_swift`:
|
||||
|
||||
| Dependency | rules_swift Version | Compatibility Level |
|
||||
|------------|---------------------|---------------------|
|
||||
| grpc | 3.x | 3 |
|
||||
| flatbuffers | 2.x | 2 |
|
||||
| rules_apple | 2.x | 2 |
|
||||
|
||||
Bzlmod's `single_version_override` can force a single version, but compatibility levels 2 and 3 are incompatible. Forcing `rules_swift` 3.x (required for grpc) breaks `rules_apple` and `flatbuffers`.
|
||||
|
||||
## Current Solution
|
||||
|
||||
The `SparklePlugin` (the only component requiring `rules_apple`) is built in an isolated workspace:
|
||||
|
||||
```
|
||||
sparkle_workspace/
|
||||
├── MODULE.bazel # Minimal deps: apple_support, rules_apple, sparkle
|
||||
├── BUILD.bazel # Builds SparklePlugin.bundle
|
||||
├── SparklePlugin.m # Objective-C source
|
||||
├── Info.plist # Bundle metadata
|
||||
└── external/
|
||||
└── BUILD.sparkle # Build file for Sparkle framework
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Build script**: `scripts/build_sparkle_plugin.sh` builds the plugin from the separate workspace
|
||||
2. **Output**: The built `SparklePlugin.bundle` is placed in `Assets/Plugins/macOS/`
|
||||
3. **CI integration**: `mac_build.yml` calls `inject_sparkle.sh` to embed Sparkle into the Mac app
|
||||
|
||||
The main workspace uses `single_version_override` for `rules_swift` 3.x to satisfy grpc, and includes a comment noting that `rules_apple` is intentionally excluded.
|
||||
|
||||
## What SparklePlugin Does
|
||||
|
||||
SparklePlugin is a native macOS library that:
|
||||
- Initializes the [Sparkle](https://sparkle-project.org/) auto-update framework
|
||||
- Exposes C functions for Unity to call via P/Invoke:
|
||||
- `SparklePlugin_Initialize`
|
||||
- `SparklePlugin_CheckForUpdates`
|
||||
- `SparklePlugin_CheckForUpdatesInBackground`
|
||||
- `SparklePlugin_IsCheckingForUpdates`
|
||||
- `SparklePlugin_GetAutomaticallyChecksForUpdates`
|
||||
- `SparklePlugin_SetAutomaticallyChecksForUpdates`
|
||||
- `SparklePlugin_IsRunningFromReadOnlyVolume`
|
||||
- `SparklePlugin_ShowDMGWarning`
|
||||
|
||||
## Conditions for Reintegration
|
||||
|
||||
To move `rules_apple` back into the main workspace, **all** of the following must be true:
|
||||
|
||||
### 1. rules_swift Version Alignment
|
||||
|
||||
Check if grpc, flatbuffers, and rules_apple all support the same `rules_swift` major version:
|
||||
|
||||
```bash
|
||||
# Check what rules_swift version each dependency requires
|
||||
bazel mod graph 2>&1 | grep -A2 "rules_swift"
|
||||
```
|
||||
|
||||
Or check BCR directly:
|
||||
- https://registry.bazel.build/modules/grpc
|
||||
- https://registry.bazel.build/modules/flatbuffers
|
||||
- https://registry.bazel.build/modules/rules_apple
|
||||
|
||||
**Test**: After updating versions, verify you can add this to MODULE.bazel without errors:
|
||||
```python
|
||||
bazel_dep(name = "rules_apple", version = "X.Y.Z")
|
||||
```
|
||||
|
||||
### 2. Build Test
|
||||
|
||||
If the dependency can be added, test that both the main build and SparklePlugin work:
|
||||
|
||||
```bash
|
||||
# Main workspace builds
|
||||
bazel build //src/main/scala/net/eagle0/eagle:eagle_server
|
||||
bazel build //src/main/cpp/net/eagle0/shardok:shardok-server
|
||||
bazel build //ci:eagle_server_image
|
||||
|
||||
# SparklePlugin builds (would move to main workspace)
|
||||
bazel build //sparkle:SparklePlugin # After moving files
|
||||
```
|
||||
|
||||
### 3. Files to Move
|
||||
|
||||
When reintegrating, move these from `sparkle_workspace/` to the main workspace:
|
||||
|
||||
| Source | Destination |
|
||||
|--------|-------------|
|
||||
| `sparkle_workspace/SparklePlugin.m` | `src/main/objc/net/eagle0/sparkle/SparklePlugin.m` |
|
||||
| `sparkle_workspace/Info.plist` | `src/main/objc/net/eagle0/sparkle/Info.plist` |
|
||||
| `sparkle_workspace/BUILD.bazel` | `src/main/objc/net/eagle0/sparkle/BUILD.bazel` |
|
||||
| `sparkle_workspace/external/BUILD.sparkle` | (inline into MODULE.bazel http_archive) |
|
||||
|
||||
### 4. MODULE.bazel Changes
|
||||
|
||||
Add to the main MODULE.bazel:
|
||||
|
||||
```python
|
||||
bazel_dep(name = "rules_apple", version = "X.Y.Z", repo_name = "build_bazel_rules_apple")
|
||||
|
||||
# Sparkle framework
|
||||
http_archive(
|
||||
name = "sparkle",
|
||||
build_file_content = """...""", # Contents from external/BUILD.sparkle
|
||||
sha256 = "...",
|
||||
url = "https://github.com/sparkle-project/Sparkle/releases/download/...",
|
||||
)
|
||||
```
|
||||
|
||||
Remove the `single_version_override` for `rules_swift` if no longer needed.
|
||||
|
||||
### 5. Update Build Scripts
|
||||
|
||||
- Update `scripts/build_sparkle_plugin.sh` to build from main workspace
|
||||
- Update `scripts/build_plugins.sh` if it references the separate workspace
|
||||
- Verify `mac_build.yml` still works
|
||||
|
||||
### 6. Cleanup
|
||||
|
||||
After successful integration:
|
||||
```bash
|
||||
rm -rf sparkle_workspace/
|
||||
```
|
||||
|
||||
Update comments in MODULE.bazel that reference the separate workspace.
|
||||
|
||||
## Version History
|
||||
|
||||
| Date | Event |
|
||||
|------|-------|
|
||||
| 2026-02-04 | Separated sparkle_workspace (PR #5882) to enable Bazel 8 upgrade |
|
||||
| 2026-02-05 | Upgraded to Bazel 8.5.1 (PR #5883) |
|
||||
|
||||
## Monitoring
|
||||
|
||||
Periodically check if the upstream dependencies have aligned:
|
||||
|
||||
1. **grpc releases**: https://github.com/grpc/grpc/releases
|
||||
2. **flatbuffers releases**: https://github.com/google/flatbuffers/releases
|
||||
3. **rules_apple releases**: https://github.com/bazelbuild/rules_apple/releases
|
||||
4. **BCR updates**: https://registry.bazel.build/
|
||||
|
||||
When a new version of any of these is released, check if the `rules_swift` requirements have converged.
|
||||
@@ -1,216 +0,0 @@
|
||||
# Shardok Latency Hiding Strategies
|
||||
|
||||
## Problem Statement
|
||||
|
||||
With Shardok running on Hetzner (Helsinki) and Eagle on DigitalOcean (US), the round-trip latency for human commands is ~200-400ms:
|
||||
|
||||
```
|
||||
Human posts command:
|
||||
Unity → Eagle (DO) → Shardok (Hetzner) → Eagle (DO) → Unity
|
||||
~10ms ~100ms ~100ms ~10ms
|
||||
Total: ~220ms round-trip
|
||||
```
|
||||
|
||||
This latency is acceptable for AI turns (users watch animations anyway), but creates noticeable lag when humans post commands.
|
||||
|
||||
---
|
||||
|
||||
## Strategy 1: Client-Side Animation Masking
|
||||
|
||||
### Concept
|
||||
|
||||
Start animations immediately when the user clicks, before server confirmation arrives. The animation duration masks the network latency.
|
||||
|
||||
### Implementation by Command Type
|
||||
|
||||
**Movement Commands** (deterministic):
|
||||
- Client knows the destination hex and movement path
|
||||
- Start movement animation immediately on click
|
||||
- Server confirms the move (should always match)
|
||||
- If server rejects (invalid state), snap unit back to origin
|
||||
|
||||
**Attack Commands** (RNG-dependent):
|
||||
- Show attack animation immediately (unit swings sword, fires arrow)
|
||||
- Wait for server to return dice roll result
|
||||
- Show damage numbers / hit effects when server responds
|
||||
- Animation typically takes 300-500ms, masking most of the latency
|
||||
|
||||
**End Turn**:
|
||||
- Latency not noticeable (user expects transition delay)
|
||||
|
||||
### Unity Implementation Sketch
|
||||
|
||||
```csharp
|
||||
// In CommandHandler.cs
|
||||
public void OnCommandSelected(Command command) {
|
||||
// Start animation immediately
|
||||
if (command.Type == CommandType.Move) {
|
||||
unitController.StartMoveAnimation(command.TargetHex);
|
||||
} else if (command.Type == CommandType.Attack) {
|
||||
unitController.StartAttackAnimation(command.TargetUnit);
|
||||
}
|
||||
|
||||
// Send to server in parallel
|
||||
connection.SendCommand(command, (response) => {
|
||||
if (response.Success) {
|
||||
// Animation continues, apply result
|
||||
ApplyCommandResult(response);
|
||||
} else {
|
||||
// Rollback animation
|
||||
unitController.CancelAnimation();
|
||||
ShowError(response.ErrorMessage);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Pros
|
||||
- Simple implementation
|
||||
- No server-side changes
|
||||
- Works with existing architecture
|
||||
|
||||
### Cons
|
||||
- Doesn't eliminate latency for attacks with RNG (must wait for dice roll)
|
||||
- Rollback needed if server rejects command (rare but possible)
|
||||
|
||||
### Estimated Improvement
|
||||
- Movement: ~200ms latency hidden (feels instant)
|
||||
- Attacks: ~100-200ms hidden by animation, ~100ms visible wait for dice result
|
||||
|
||||
---
|
||||
|
||||
## Strategy 2: Split Shardok Architecture
|
||||
|
||||
### Concept
|
||||
|
||||
Run two Shardok instances:
|
||||
- **Shardok-Primary (DigitalOcean)**: Handles command processing, source of truth
|
||||
- **Shardok-AI (Hetzner)**: AI computation only
|
||||
|
||||
Human commands go to the nearby Primary for low latency. AI computation uses the powerful Hetzner instance.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Human commands (low latency ~20ms)
|
||||
Unity ←→ Eagle ←→ Shardok-Primary (DigitalOcean)
|
||||
↓ state sync (when AI turn starts)
|
||||
Shardok-AI (Hetzner)
|
||||
↑ AI command response
|
||||
```
|
||||
|
||||
### How It Works
|
||||
|
||||
**Human Turn:**
|
||||
1. Human posts command → Eagle → Shardok-Primary (DO)
|
||||
2. Primary processes command immediately (~10ms local)
|
||||
3. Primary streams result to client via Eagle (~10ms)
|
||||
4. **Total latency: ~20ms** (vs ~220ms current)
|
||||
|
||||
**AI Turn:**
|
||||
1. When AI's turn starts, Primary sends game state snapshot to Hetzner
|
||||
2. Shardok-AI computes best command using full CPU power
|
||||
3. Shardok-AI returns command index to Primary
|
||||
4. Primary executes command locally and streams to client
|
||||
5. Repeat until AI turn ends
|
||||
|
||||
### Protocol Changes
|
||||
|
||||
```protobuf
|
||||
// New service for AI-only computation
|
||||
service ShardokAIService {
|
||||
// Send game state, receive AI's chosen command
|
||||
rpc GetAICommand(AICommandRequest) returns (AICommandResponse);
|
||||
}
|
||||
|
||||
message AICommandRequest {
|
||||
bytes game_state = 1; // Serialized game state
|
||||
int32 player_id = 2; // Which AI player
|
||||
repeated bytes available_commands = 3; // Available command descriptors
|
||||
}
|
||||
|
||||
message AICommandResponse {
|
||||
int32 command_index = 1; // Index into available_commands
|
||||
int32 search_depth = 2; // For debugging
|
||||
double best_score = 3; // For debugging
|
||||
}
|
||||
```
|
||||
|
||||
### Shardok-Primary Requirements
|
||||
|
||||
Shardok-Primary on DigitalOcean needs to:
|
||||
- Process all commands (human and AI)
|
||||
- Maintain authoritative game state
|
||||
- Serialize/deserialize game state for AI requests
|
||||
- Run on minimal CPU (command processing is fast)
|
||||
|
||||
This is essentially the current Shardok, but without running the AI search.
|
||||
|
||||
### Shardok-AI Requirements
|
||||
|
||||
Shardok-AI on Hetzner needs to:
|
||||
- Receive game state snapshots
|
||||
- Run AI evaluation (IterativeDeepeningAI or MCTS)
|
||||
- Return best command index
|
||||
- No persistent state (stateless worker)
|
||||
|
||||
### AI Turn Latency
|
||||
|
||||
Each AI command has ~200ms network latency. This is acceptable because:
|
||||
1. User is watching animations anyway
|
||||
2. Natural pacing lets user observe AI decisions
|
||||
3. AI computation is fast on Hetzner's 16 cores
|
||||
|
||||
For a typical AI turn with 5 commands: 5 × 200ms = 1 second network overhead, plus AI thinking time. With animations, this feels natural.
|
||||
|
||||
### Implementation Phases
|
||||
|
||||
**Phase 1: Add Shardok-Primary (minimal)**
|
||||
- Deploy existing Shardok container to DigitalOcean
|
||||
- Configure Eagle to use local Shardok for all commands
|
||||
- Human latency immediately improves
|
||||
|
||||
**Phase 2: Add AI offload**
|
||||
- Implement `ShardokAIService` RPC
|
||||
- Shardok-Primary calls Hetzner for AI commands
|
||||
- Shardok-AI processes requests statelessly
|
||||
|
||||
**Phase 3: Optimize**
|
||||
- Batch multiple AI actions if possible
|
||||
- Pre-warm Shardok-AI connection
|
||||
- Add fallback if Hetzner unavailable
|
||||
|
||||
### Pros
|
||||
- Human command latency drops from ~220ms to ~20ms
|
||||
- AI still gets Hetzner's CPU power
|
||||
- Clear separation of concerns
|
||||
- Shardok-Primary can fall back to local AI if Hetzner unavailable
|
||||
|
||||
### Cons
|
||||
- Two Shardok instances to maintain
|
||||
- State serialization overhead for AI requests
|
||||
- Each AI action has network round-trip (acceptable with animations)
|
||||
|
||||
---
|
||||
|
||||
## Comparison
|
||||
|
||||
| Approach | Human Latency | AI Throughput | Complexity | Changes Required |
|
||||
|----------|---------------|---------------|------------|------------------|
|
||||
| Current | ~220ms | High | - | - |
|
||||
| Animation masking | ~220ms (perceived ~50ms) | High | Low | Unity only |
|
||||
| Split architecture | ~20ms | High | Medium | New RPC, two deployments |
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Phase 1 (now)**: Implement animation masking in Unity client
|
||||
- Quick win, no server changes
|
||||
- Improves perceived latency significantly for movement
|
||||
- Attacks still show dice animation while waiting
|
||||
|
||||
**Phase 2 (future)**: Split architecture if animation masking insufficient
|
||||
- Only needed if users complain about attack latency
|
||||
- More complex but provides true low latency
|
||||
- Natural evolution of current architecture
|
||||
@@ -1,80 +0,0 @@
|
||||
# The Small Eagle TODO
|
||||
|
||||
## Goals
|
||||
|
||||
Be able to support a small (10-50 user) private alpha, including with strangers.
|
||||
|
||||
Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/17RTt3-4Wl2AAVMRLodaC3a84E4de6xuQTWBPvCRM484/edit?pli=1&tab=t.0), but most of that is not necessary for MVP.
|
||||
|
||||
## Required
|
||||
|
||||
### Gameplay Productionization
|
||||
|
||||
- [x] ~~All functionality works on production eagle / shardok servers~~
|
||||
- [x] ~~Acceptable latency in all regions~~
|
||||
- [x] ~~Shardok performance similar to QA~~
|
||||
- [x] ~~Error logging & alerting~~
|
||||
- [x] ~~Fix long disconnects on deployments~~
|
||||
- [x] Fix the Mac installer
|
||||
- [x] ~~Still not reconnecting after deployments~~
|
||||
- [ ] Notify about client updates, button to come directly back
|
||||
- [x] Generatedtext healing
|
||||
- [x] Kill outstanding shardok requests when game is deleted
|
||||
|
||||
### Other Productionization
|
||||
|
||||
- [x] ~~Oauth sign-in~~
|
||||
- [x] ~~Add Google, others?~~
|
||||
- [ ] User management
|
||||
- [x] ~~Invite codes~~
|
||||
- [ ] Link accounts
|
||||
- [x] ~~Choose display name~~
|
||||
- [x] ~~Just do account setup from the landing page?~~
|
||||
- [x] ~~Download client directly from DO, avoid basic auth/my home network~~
|
||||
- [x] Support plan (Discord + in-client bug reporting + email contact)
|
||||
|
||||
### Alpha Tester Support
|
||||
|
||||
- [x] Feedback channel (Discord server)
|
||||
- [x] Bug report form in Unity client (Settings > Report Bug, sends to Discord webhook)
|
||||
- [ ] Known issues doc (so testers don't report the same things)
|
||||
|
||||
### IP / Legal
|
||||
|
||||
- [x] Document and make available licenses for art & music (attributions panel)
|
||||
- [x] Required open source disclosures (attributions panel)
|
||||
- [x] Audit assets for anything we don't have rights to and replace it
|
||||
- [x] Replace heroes that are based on real 20th or 21st century people or IP
|
||||
- [x] ~~Privacy policy (collecting accounts, OAuth data, gameplay data)~~ (alpha notice on invite page/email)
|
||||
- [ ] Terms of service (basic liability protection) - defer until public release
|
||||
- [x] ~~Data deletion capability (user requests account removal)~~ (accounts.eagle0.net)
|
||||
|
||||
### Basic Gameplay
|
||||
|
||||
- [ ] Tutorial
|
||||
- [x] In the Your Warlord panel, say what the profession is
|
||||
- [x] ~~And separate panels for each profession when you encounter one~~
|
||||
- [x] ~~Command tutorial for each command the first time it's clicked~~
|
||||
- [x] ~~Time to recruit / expand~~
|
||||
- [x] ~~And how expansion works~~
|
||||
- [ ] Province events
|
||||
- [x] Running low on food
|
||||
- [x] ~~Time to swear brotherhood~~
|
||||
- [x] ~~When you get large, or~~
|
||||
- [x] ~~When you get a good candidate~~
|
||||
- [ ] Shardok tutorial!
|
||||
- [ ] Basic Shardok AI stuff fixed
|
||||
- [ ] Lobby fixes
|
||||
- [ ] Have goals / ending
|
||||
- [ ] Win condition: all other factions defeated
|
||||
- [ ] Mid-game progression: King recognizes you as you gain power (generated events)
|
||||
- [ ] First-session onboarding (beyond mechanics tutorial)
|
||||
- [ ] Narrative hook in first few minutes - why should I care about my warlord?
|
||||
- [ ] Clear first-session goal ("try to capture your first province" or similar)
|
||||
- [ ] Early small victory to build momentum
|
||||
- [ ] Guided first scenario vs. overwhelming sandbox?
|
||||
|
||||
## Nice to have
|
||||
|
||||
- [x] "What's new" changelog (fetch JSON, show entries since last launch)
|
||||
- [ ] Windows code signing ([Azure Artifact Signing](https://azure.microsoft.com/en-us/products/artifact-signing/), ~$10/mo) to eliminate SmartScreen "unknown publisher" warning
|
||||
@@ -1,568 +0,0 @@
|
||||
# Sparkle Delta Updates Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the implementation plan for adding delta update support to the Eagle0 macOS auto-update system using Sparkle's BinaryDelta feature.
|
||||
|
||||
### Current State
|
||||
- Full DMG downloads (~200MB) for every update
|
||||
- `mac_build_handler.go` creates DMG, signs it, uploads to S3, updates appcast.xml
|
||||
- Keeps last 10 versions in appcast, deletes older DMGs
|
||||
- Users must download full app even for small changes
|
||||
|
||||
### Goals
|
||||
- Reduce update download size from ~200MB to ~10-30MB (85% reduction)
|
||||
- Maintain backward compatibility with full DMG downloads
|
||||
- Automatic fallback for users who are many versions behind
|
||||
|
||||
## Sparkle Delta Update Architecture
|
||||
|
||||
Sparkle supports binary delta updates through the `<sparkle:deltas>` element in the appcast. When a user updates, Sparkle:
|
||||
1. Checks if a delta patch exists from their current version to the new version
|
||||
2. If found, downloads the smaller delta patch instead of the full DMG
|
||||
3. Applies the patch locally to create the new app version
|
||||
4. Falls back to full DMG if no matching delta exists
|
||||
|
||||
### Appcast XML Structure with Deltas
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
|
||||
<channel>
|
||||
<title>Eagle0</title>
|
||||
<link>https://assets.eagle0.net/mac/appcast.xml</link>
|
||||
<description>Eagle0 game updates</description>
|
||||
<language>en</language>
|
||||
<item>
|
||||
<title>Version 1.0.9615</title>
|
||||
<pubDate>Sun, 19 Jan 2026 12:00:00 -0800</pubDate>
|
||||
<sparkle:version>9615</sparkle:version>
|
||||
<sparkle:shortVersionString>1.0.9615</sparkle:shortVersionString>
|
||||
<enclosure
|
||||
url="https://assets.eagle0.net/mac/builds/eagle0-1.0.9615.dmg"
|
||||
length="200000000"
|
||||
type="application/octet-stream"
|
||||
sparkle:edSignature="..." />
|
||||
<sparkle:deltas>
|
||||
<enclosure
|
||||
url="https://assets.eagle0.net/mac/deltas/9614-9615.delta"
|
||||
sparkle:deltaFrom="9614"
|
||||
length="15000000"
|
||||
type="application/octet-stream"
|
||||
sparkle:edSignature="..." />
|
||||
<enclosure
|
||||
url="https://assets.eagle0.net/mac/deltas/9613-9615.delta"
|
||||
sparkle:deltaFrom="9613"
|
||||
length="18000000"
|
||||
type="application/octet-stream"
|
||||
sparkle:edSignature="..." />
|
||||
<enclosure
|
||||
url="https://assets.eagle0.net/mac/deltas/9612-9615.delta"
|
||||
sparkle:deltaFrom="9612"
|
||||
length="22000000"
|
||||
type="application/octet-stream"
|
||||
sparkle:edSignature="..." />
|
||||
</sparkle:deltas>
|
||||
</item>
|
||||
<!-- older versions... -->
|
||||
</channel>
|
||||
</rss>
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Add S3 Utility Functions
|
||||
|
||||
**File:** `src/main/go/net/eagle0/util/aws/bucket_basics.go`
|
||||
|
||||
Add two new functions to support delta generation:
|
||||
|
||||
```go
|
||||
// ListObjectsWithPrefix returns all object keys matching the given prefix
|
||||
func (bb BucketBasics) ListObjectsWithPrefix(bucket, prefix string) ([]string, error) {
|
||||
var keys []string
|
||||
paginator := s3.NewListObjectsV2Paginator(bb.S3Client, &s3.ListObjectsV2Input{
|
||||
Bucket: aws.String(bucket),
|
||||
Prefix: aws.String(prefix),
|
||||
})
|
||||
|
||||
for paginator.HasMorePages() {
|
||||
page, err := paginator.NextPage(context.TODO())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, obj := range page.Contents {
|
||||
keys = append(keys, *obj.Key)
|
||||
}
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// DownloadFile downloads an object to a local file path
|
||||
func (bb BucketBasics) DownloadFile(bucket, key, localPath string) error {
|
||||
result, err := bb.S3Client.GetObject(context.TODO(), &s3.GetObjectInput{
|
||||
Bucket: aws.String(bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer result.Body.Close()
|
||||
|
||||
file, err := os.Create(localPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, result.Body)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2: Store App Bundles for Delta Generation
|
||||
|
||||
**File:** `src/main/go/net/eagle0/build/mac_build_handler/mac_build_handler.go`
|
||||
|
||||
Add storage paths:
|
||||
```go
|
||||
var appsRoot = "mac/apps/" // Zipped app bundles for delta generation
|
||||
var deltasRoot = "mac/deltas/" // Delta patches
|
||||
```
|
||||
|
||||
After DMG creation, upload the zipped app bundle:
|
||||
```go
|
||||
func uploadAppBundle(bb aws.BucketBasics, appPath string, buildNumber string) error {
|
||||
appZipPath := filepath.Join("/tmp", fmt.Sprintf("eagle0-%s.app.zip", buildNumber))
|
||||
|
||||
// Create zip of app bundle using ditto (preserves metadata)
|
||||
cmd := exec.Command("ditto", "-c", "-k", "--keepParent", appPath, appZipPath)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("failed to zip app: %s: %w", string(output), err)
|
||||
}
|
||||
defer os.Remove(appZipPath)
|
||||
|
||||
// Upload to S3
|
||||
remotePath := appsRoot + fmt.Sprintf("eagle0-%s.app.zip", buildNumber)
|
||||
log.Printf("Uploading app bundle to S3: %s", remotePath)
|
||||
return bb.UploadFilePublic(bucketName, remotePath, appZipPath)
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Add Delta XML Structures
|
||||
|
||||
**File:** `src/main/go/net/eagle0/build/mac_build_handler/mac_build_handler.go`
|
||||
|
||||
Add new structs for delta representation:
|
||||
```go
|
||||
// Delta represents a delta patch from a previous version
|
||||
type Delta struct {
|
||||
XMLName xml.Name `xml:"enclosure"`
|
||||
URL string `xml:"url,attr"`
|
||||
DeltaFrom string `xml:"sparkle:deltaFrom,attr"`
|
||||
Length int64 `xml:"length,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
EdSig string `xml:"sparkle:edSignature,attr"`
|
||||
}
|
||||
|
||||
// Deltas wraps the sparkle:deltas element
|
||||
type Deltas struct {
|
||||
XMLName xml.Name `xml:"sparkle:deltas"`
|
||||
Items []Delta `xml:"enclosure"`
|
||||
}
|
||||
|
||||
// Update Item struct to include Deltas
|
||||
type Item struct {
|
||||
Title string `xml:"title"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
SparkleVersion string `xml:"sparkle:version"`
|
||||
SparkleShortVersion string `xml:"sparkle:shortVersionString"`
|
||||
Description string `xml:"description,omitempty"`
|
||||
Enclosure Enclosure `xml:"enclosure"`
|
||||
Deltas *Deltas `xml:"sparkle:deltas,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4: Generate Delta Patches
|
||||
|
||||
**File:** `src/main/go/net/eagle0/build/mac_build_handler/mac_build_handler.go`
|
||||
|
||||
```go
|
||||
// Maximum number of versions to generate deltas from
|
||||
const maxDeltaVersions = 5
|
||||
|
||||
// generateDeltas creates delta patches from previous versions to the new version
|
||||
func generateDeltas(bb aws.BucketBasics, newBuildNumber string, newAppPath string, privateKeyPath string) ([]Delta, error) {
|
||||
var deltas []Delta
|
||||
|
||||
// Ensure BinaryDelta tool is available
|
||||
binaryDeltaPath, err := ensureBinaryDelta()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get BinaryDelta: %w", err)
|
||||
}
|
||||
|
||||
// List available app bundles
|
||||
appKeys, err := bb.ListObjectsWithPrefix(bucketName, appsRoot+"eagle0-")
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to list app bundles: %v", err)
|
||||
return deltas, nil // Continue without deltas
|
||||
}
|
||||
|
||||
// Parse build numbers from keys and sort descending
|
||||
var buildNumbers []string
|
||||
for _, key := range appKeys {
|
||||
// Extract build number from "mac/apps/eagle0-9614.app.zip"
|
||||
base := filepath.Base(key)
|
||||
if strings.HasPrefix(base, "eagle0-") && strings.HasSuffix(base, ".app.zip") {
|
||||
bn := strings.TrimSuffix(strings.TrimPrefix(base, "eagle0-"), ".app.zip")
|
||||
if bn != newBuildNumber {
|
||||
buildNumbers = append(buildNumbers, bn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort descending (most recent first) and limit to maxDeltaVersions
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(buildNumbers)))
|
||||
if len(buildNumbers) > maxDeltaVersions {
|
||||
buildNumbers = buildNumbers[:maxDeltaVersions]
|
||||
}
|
||||
|
||||
// Generate delta from each previous version
|
||||
for _, oldBuild := range buildNumbers {
|
||||
delta, err := generateSingleDelta(bb, binaryDeltaPath, oldBuild, newBuildNumber, newAppPath, privateKeyPath)
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to generate delta from %s: %v", oldBuild, err)
|
||||
continue // Skip this delta but continue with others
|
||||
}
|
||||
deltas = append(deltas, delta)
|
||||
}
|
||||
|
||||
return deltas, nil
|
||||
}
|
||||
|
||||
func generateSingleDelta(bb aws.BucketBasics, binaryDeltaPath, oldBuild, newBuild, newAppPath, privateKeyPath string) (Delta, error) {
|
||||
// Download old app bundle
|
||||
oldAppZipKey := appsRoot + fmt.Sprintf("eagle0-%s.app.zip", oldBuild)
|
||||
oldAppZipLocal := filepath.Join("/tmp", fmt.Sprintf("eagle0-%s.app.zip", oldBuild))
|
||||
defer os.Remove(oldAppZipLocal)
|
||||
|
||||
if err := bb.DownloadFile(bucketName, oldAppZipKey, oldAppZipLocal); err != nil {
|
||||
return Delta{}, fmt.Errorf("failed to download old app: %w", err)
|
||||
}
|
||||
|
||||
// Unzip old app
|
||||
oldAppDir := filepath.Join("/tmp", fmt.Sprintf("old-app-%s", oldBuild))
|
||||
defer os.RemoveAll(oldAppDir)
|
||||
|
||||
cmd := exec.Command("ditto", "-x", "-k", oldAppZipLocal, oldAppDir)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return Delta{}, fmt.Errorf("failed to unzip old app: %s: %w", string(output), err)
|
||||
}
|
||||
|
||||
oldAppPath := filepath.Join(oldAppDir, "eagle0.app")
|
||||
|
||||
// Generate delta
|
||||
deltaPath := filepath.Join("/tmp", fmt.Sprintf("%s-%s.delta", oldBuild, newBuild))
|
||||
defer os.Remove(deltaPath)
|
||||
|
||||
cmd = exec.Command(binaryDeltaPath, "create", oldAppPath, newAppPath, deltaPath)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return Delta{}, fmt.Errorf("failed to create delta: %s: %w", string(output), err)
|
||||
}
|
||||
|
||||
// Get delta size
|
||||
deltaSize, err := getFileSize(deltaPath)
|
||||
if err != nil {
|
||||
return Delta{}, fmt.Errorf("failed to get delta size: %w", err)
|
||||
}
|
||||
log.Printf("Delta %s->%s size: %d bytes (%.1f MB)", oldBuild, newBuild, deltaSize, float64(deltaSize)/1024/1024)
|
||||
|
||||
// Sign delta
|
||||
signature, err := signWithSparkle(deltaPath, privateKeyPath)
|
||||
if err != nil {
|
||||
return Delta{}, fmt.Errorf("failed to sign delta: %w", err)
|
||||
}
|
||||
|
||||
// Upload delta
|
||||
deltaKey := deltasRoot + fmt.Sprintf("%s-%s.delta", oldBuild, newBuild)
|
||||
if err := bb.UploadFilePublic(bucketName, deltaKey, deltaPath); err != nil {
|
||||
return Delta{}, fmt.Errorf("failed to upload delta: %w", err)
|
||||
}
|
||||
|
||||
deltaURL := fmt.Sprintf("https://assets.eagle0.net/%s", deltaKey)
|
||||
return Delta{
|
||||
URL: deltaURL,
|
||||
DeltaFrom: oldBuild,
|
||||
Length: deltaSize,
|
||||
Type: "application/octet-stream",
|
||||
EdSig: signature,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ensureBinaryDelta() (string, error) {
|
||||
binaryDeltaPath := "/tmp/sparkle-cache/Sparkle-2.6.4/bin/BinaryDelta"
|
||||
|
||||
if _, err := os.Stat(binaryDeltaPath); os.IsNotExist(err) {
|
||||
log.Println("Sparkle BinaryDelta not found, downloading...")
|
||||
cmd := exec.Command("bash", "-c", `
|
||||
mkdir -p /tmp/sparkle-cache
|
||||
curl -sL https://github.com/sparkle-project/Sparkle/releases/download/2.6.4/Sparkle-2.6.4.tar.xz | tar -xJ -C /tmp/sparkle-cache
|
||||
`)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return "", fmt.Errorf("failed to download Sparkle: %s: %w", string(output), err)
|
||||
}
|
||||
}
|
||||
|
||||
return binaryDeltaPath, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 5: Update Main Deploy Flow
|
||||
|
||||
**File:** `src/main/go/net/eagle0/build/mac_build_handler/mac_build_handler.go`
|
||||
|
||||
Modify `main()` to integrate delta generation:
|
||||
|
||||
```go
|
||||
func main() {
|
||||
// ... existing argument parsing ...
|
||||
|
||||
// Create DMG (existing)
|
||||
if err := createDMG(appPath, dmgPath, "Eagle0"); err != nil {
|
||||
log.Fatalf("Failed to create DMG: %v", err)
|
||||
}
|
||||
|
||||
// ... existing DMG upload ...
|
||||
|
||||
if privateKeyPath != "" {
|
||||
// Upload app bundle for future delta generation (NEW)
|
||||
log.Println("Uploading app bundle for delta generation...")
|
||||
if err := uploadAppBundle(bb, appPath, buildNumber); err != nil {
|
||||
log.Printf("Warning: failed to upload app bundle: %v", err)
|
||||
// Continue - delta generation is optional
|
||||
}
|
||||
|
||||
// Generate deltas from previous versions (NEW)
|
||||
log.Println("Generating delta patches...")
|
||||
deltas, err := generateDeltas(bb, buildNumber, appPath, privateKeyPath)
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to generate deltas: %v", err)
|
||||
} else {
|
||||
log.Printf("Generated %d delta patches", len(deltas))
|
||||
}
|
||||
|
||||
// Update appcast with deltas
|
||||
log.Println("Updating appcast.xml...")
|
||||
appcast, err := fetchAppcast(bb)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to fetch appcast: %v", err)
|
||||
}
|
||||
|
||||
// Create new item with deltas
|
||||
newItem := Item{
|
||||
Title: fmt.Sprintf("Version %s", version),
|
||||
PubDate: time.Now().Format(time.RFC1123Z),
|
||||
SparkleVersion: buildNumber,
|
||||
SparkleShortVersion: version,
|
||||
Description: "",
|
||||
Enclosure: Enclosure{
|
||||
URL: downloadURL,
|
||||
Length: fileSize,
|
||||
Type: "application/octet-stream",
|
||||
EdSig: signature,
|
||||
},
|
||||
}
|
||||
|
||||
// Add deltas if any were generated
|
||||
if len(deltas) > 0 {
|
||||
newItem.Deltas = &Deltas{Items: deltas}
|
||||
}
|
||||
|
||||
// ... rest of appcast handling ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 6: Cleanup Old Artifacts
|
||||
|
||||
**File:** `src/main/go/net/eagle0/build/mac_build_handler/mac_build_handler.go`
|
||||
|
||||
When pruning old versions from appcast, also delete associated artifacts:
|
||||
|
||||
```go
|
||||
// In the appcast pruning section, after removing old items:
|
||||
if len(appcast.Channel.Items) > 10 {
|
||||
oldItems := appcast.Channel.Items[10:]
|
||||
for _, item := range oldItems {
|
||||
oldBuild := item.SparkleVersion
|
||||
|
||||
// Delete old DMG (existing)
|
||||
dmgKey := strings.TrimPrefix(item.Enclosure.URL, "https://assets.eagle0.net/")
|
||||
log.Printf("Deleting old build: %s", dmgKey)
|
||||
bb.DeleteObject(bucketName, dmgKey)
|
||||
|
||||
// Delete old app bundle (NEW)
|
||||
appKey := appsRoot + fmt.Sprintf("eagle0-%s.app.zip", oldBuild)
|
||||
log.Printf("Deleting old app bundle: %s", appKey)
|
||||
bb.DeleteObject(bucketName, appKey)
|
||||
|
||||
// Delete deltas TO this version (NEW)
|
||||
deltaKeys, _ := bb.ListObjectsWithPrefix(bucketName, deltasRoot)
|
||||
for _, key := range deltaKeys {
|
||||
if strings.HasSuffix(key, fmt.Sprintf("-%s.delta", oldBuild)) {
|
||||
log.Printf("Deleting old delta: %s", key)
|
||||
bb.DeleteObject(bucketName, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
appcast.Channel.Items = appcast.Channel.Items[:10]
|
||||
}
|
||||
```
|
||||
|
||||
## S3 Storage Structure
|
||||
|
||||
After implementation, the S3 bucket will have this structure:
|
||||
|
||||
```
|
||||
eagle0-windows/
|
||||
├── mac/
|
||||
│ ├── appcast.xml # Update feed with delta info
|
||||
│ ├── builds/ # Full DMG downloads
|
||||
│ │ ├── eagle0-1.0.9620.dmg
|
||||
│ │ ├── eagle0-1.0.9619.dmg
|
||||
│ │ ├── ...
|
||||
│ │ └── eagle0-latest.dmg # Symlink to latest
|
||||
│ ├── apps/ # Zipped app bundles (NEW)
|
||||
│ │ ├── eagle0-9620.app.zip
|
||||
│ │ ├── eagle0-9619.app.zip
|
||||
│ │ ├── eagle0-9618.app.zip
|
||||
│ │ ├── eagle0-9617.app.zip
|
||||
│ │ └── eagle0-9616.app.zip # Keep last 5 for delta gen
|
||||
│ └── deltas/ # Delta patches (NEW)
|
||||
│ ├── 9619-9620.delta
|
||||
│ ├── 9618-9620.delta
|
||||
│ ├── 9617-9620.delta
|
||||
│ ├── 9616-9620.delta
|
||||
│ ├── 9615-9620.delta
|
||||
│ ├── 9618-9619.delta
|
||||
│ ├── 9617-9619.delta
|
||||
│ └── ...
|
||||
```
|
||||
|
||||
## Storage Impact Analysis
|
||||
|
||||
### Current Storage (without deltas)
|
||||
- 10 DMGs × 200MB = **~2GB**
|
||||
|
||||
### Estimated Storage (with deltas)
|
||||
- 10 DMGs × 200MB = 2GB
|
||||
- 5 app bundles × 150MB = 0.75GB (zip compression)
|
||||
- ~25 delta files × 20MB avg = 0.5GB
|
||||
- **Total: ~3.25GB**
|
||||
|
||||
### Trade-offs
|
||||
- **+1.25GB storage** (~60% increase)
|
||||
- **-170MB per user update** (~85% bandwidth savings)
|
||||
- Break-even: ~8 user updates to recoup storage cost
|
||||
|
||||
## Bandwidth Savings
|
||||
|
||||
| Scenario | Without Deltas | With Deltas | Savings |
|
||||
|----------|---------------|-------------|---------|
|
||||
| 1 version behind | 200MB | ~15MB | 92% |
|
||||
| 2 versions behind | 200MB | ~20MB | 90% |
|
||||
| 3 versions behind | 200MB | ~25MB | 87% |
|
||||
| 5 versions behind | 200MB | ~35MB | 82% |
|
||||
| 6+ versions behind | 200MB | 200MB (full) | 0% |
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
The implementation is backward-compatible and requires no changes to existing clients:
|
||||
|
||||
1. **First deploy after implementation:**
|
||||
- Stores app bundle for the first time
|
||||
- No deltas generated (no previous app bundles exist)
|
||||
- Appcast has no `<sparkle:deltas>` element
|
||||
|
||||
2. **Second deploy:**
|
||||
- Generates delta from previous version
|
||||
- Appcast now has `<sparkle:deltas>` with one entry
|
||||
- Users on previous version get delta update
|
||||
|
||||
3. **Subsequent deploys:**
|
||||
- Generate deltas from last 5 versions
|
||||
- Users within 5 versions get delta updates
|
||||
- Users more than 5 versions behind get full DMG
|
||||
|
||||
4. **Client behavior:**
|
||||
- Sparkle automatically checks for matching delta
|
||||
- Falls back to full DMG if no delta matches
|
||||
- No client code changes required
|
||||
|
||||
## Error Handling
|
||||
|
||||
The implementation handles failures gracefully:
|
||||
|
||||
1. **S3 list/download fails:** Skip delta generation, use full DMG
|
||||
2. **BinaryDelta fails for one version:** Log warning, continue with other versions
|
||||
3. **Signing fails:** Skip that delta, continue with others
|
||||
4. **Upload fails:** Skip that delta, continue with others
|
||||
|
||||
The deploy never fails due to delta issues - deltas are optional enhancements.
|
||||
|
||||
## Verification Plan
|
||||
|
||||
### Manual Testing
|
||||
|
||||
1. **Deploy version N:**
|
||||
- Verify app bundle uploaded to `mac/apps/eagle0-N.app.zip`
|
||||
- Verify appcast has no deltas (first deploy)
|
||||
|
||||
2. **Deploy version N+1:**
|
||||
- Verify delta generated at `mac/deltas/N-(N+1).delta`
|
||||
- Verify appcast contains `<sparkle:deltas>` element
|
||||
- Verify delta signature is valid
|
||||
|
||||
3. **Test update from N to N+1:**
|
||||
- Install version N manually
|
||||
- Check for updates
|
||||
- Monitor download size in Player.log (should be ~15-30MB, not 200MB)
|
||||
- Verify app updated successfully
|
||||
|
||||
4. **Test fresh install:**
|
||||
- Download latest DMG directly
|
||||
- Verify installation works normally
|
||||
|
||||
5. **Test fallback scenario:**
|
||||
- Install a version more than 5 versions behind
|
||||
- Update should download full DMG
|
||||
|
||||
### Automated Verification
|
||||
|
||||
Add to CI workflow (optional):
|
||||
```yaml
|
||||
- name: Verify delta generation
|
||||
run: |
|
||||
# Check app bundle exists
|
||||
aws s3 ls s3://eagle0-windows/mac/apps/ | grep eagle0-${BUILD_NUMBER}.app.zip
|
||||
|
||||
# Check deltas exist (after second deploy)
|
||||
aws s3 ls s3://eagle0-windows/mac/deltas/ | head -5
|
||||
|
||||
# Verify appcast has deltas
|
||||
curl -s https://assets.eagle0.net/mac/appcast.xml | grep "sparkle:deltas"
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **All deltas are EdDSA signed:** Same signature verification as full DMG
|
||||
2. **BinaryDelta is Sparkle's official tool:** Well-audited, production-ready
|
||||
3. **App bundles in S3 are public:** Same as DMGs, no additional exposure
|
||||
4. **Cleanup removes old artifacts:** No indefinite storage of old versions
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Parallel delta generation:** Generate multiple deltas concurrently
|
||||
2. **Delta size threshold:** Skip uploading deltas larger than X% of full DMG
|
||||
3. **Delta metrics:** Track delta download rates vs full DMG
|
||||
4. **Configurable delta count:** Allow adjusting how many versions to keep
|
||||
@@ -1,267 +0,0 @@
|
||||
# Tutorial Battle System
|
||||
|
||||
This document describes the tutorial battle system for first-time players. The system provides a scripted introductory battle that teaches combat basics while telling a story.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
When a new player starts their first game in tutorial mode, they experience:
|
||||
|
||||
1. **Narrative Intro** - Story screens introducing the scenario
|
||||
2. **Immediate Battle** - Skip strategic map, start directly in combat
|
||||
3. **Scripted Flee** - Enemy flees when player is "on the ropes"
|
||||
4. **Reinforcements** - Allied heroes arrive mid-battle
|
||||
5. **Heroes Join** - Reinforcement heroes join player's faction after battle
|
||||
|
||||
---
|
||||
|
||||
## Battle Configuration
|
||||
|
||||
### Defender (Player - John Ranil)
|
||||
- **Heroes**: John Ranil + 2 random vassals (3 total)
|
||||
- **Units**:
|
||||
- 2x Light Infantry (300 troops each, 60 training/armament)
|
||||
- 1x Longbowmen (200 troops, 60 training/armament)
|
||||
- **Province**: 14 (Onmaa) with 40 support, 500 gold, 3000 food
|
||||
- **Restriction**: Cannot flee (must defend)
|
||||
|
||||
### Attacker (Ikhaan Tarn)
|
||||
- **Heroes**: Ikhaan Tarn + 2 sworn brothers (3 total)
|
||||
- **Units**:
|
||||
- 1x Heavy Cavalry (600 troops, 80 training/armament)
|
||||
- 1x Heavy Infantry (500 troops, 80 training/armament)
|
||||
- 1x Longbowmen (300 troops, 80 training/armament)
|
||||
- **Origin Province**: 32
|
||||
|
||||
### Flee Trigger
|
||||
Attacker flees when ANY of these conditions are met:
|
||||
- Player loses 1 unit
|
||||
- 5 rounds have passed
|
||||
|
||||
### Reinforcements
|
||||
When Tarn flees, these heroes arrive as player reinforcements:
|
||||
- Elena Fyar
|
||||
- Hedrick
|
||||
- The Boulder
|
||||
|
||||
---
|
||||
|
||||
## Implementation Architecture
|
||||
|
||||
### Phase 1: Narrative Intro System
|
||||
|
||||
**Proto Changes:**
|
||||
- `game_state_view.proto`: Added `TutorialNarrativeScreen` message and `pending_narrative_screens` field
|
||||
- `game_parameters.proto`: Added `TutorialNarrativeScreen` and `tutorial_narrative_screens` field
|
||||
|
||||
**Unity Client:**
|
||||
- `NarrativeScreenController.cs`: Modal screen with title, body text, optional image
|
||||
- `EagleGameController.cs`: Checks for pending narrative screens on game start
|
||||
|
||||
**Narrative Content** (defined in `tutorial_game_parameters.json`):
|
||||
```
|
||||
Screen 1: "The Engineer of Onmaa" - Player backstory
|
||||
Screen 2: "The Traitor Arrives" - Tarn's attack
|
||||
Screen 3: "Defend Your Home" - Call to action
|
||||
```
|
||||
|
||||
### Phase 2: Auto-Start Battle
|
||||
|
||||
**Configuration:**
|
||||
- `tutorial_game_parameters.json`: Contains `tutorialBattle` config with attacker, target, flee conditions
|
||||
- `game_parameters.proto`: `TutorialBattleConfig` message
|
||||
|
||||
**Game Creation:**
|
||||
- `NewGameCreation.scala`: `setupTutorialBattle()` method creates:
|
||||
- `HostileArmyGroup` with `Attacking` status (bypasses decision phase)
|
||||
- `Army` for defender
|
||||
- Both added to target province
|
||||
|
||||
**Battle Creation:**
|
||||
- When round advances to `BattleRequest` phase, `RequestBattlesAction` automatically creates the `ShardokBattle`
|
||||
|
||||
### Phase 3: Scripted Flee Mechanism
|
||||
|
||||
**C++ Component:**
|
||||
- `TutorialBattleController.hpp/cpp`: Controller for scripted battle logic
|
||||
|
||||
**Key Methods:**
|
||||
```cpp
|
||||
// Check all events and execute triggered actions (called at end of each round)
|
||||
std::vector<ActionResult> CheckAndExecuteEvents(
|
||||
const GameStateW& state,
|
||||
const SettingsGetter& settings,
|
||||
const std::shared_ptr<RandomGenerator>& randomGenerator);
|
||||
```
|
||||
|
||||
**Integration:**
|
||||
- `ShardokEngine.cpp`: Calls `CheckAndExecuteEvents()` at end of each round in `HandlePlayerTurnEnd()`
|
||||
- Events fire in config order, each event fires at most once
|
||||
|
||||
### Phase 4: Mid-Battle Reinforcements
|
||||
|
||||
**Action Types** (in `action_type.proto`):
|
||||
- `TUTORIAL_ENEMY_FLED = 78`
|
||||
- `TUTORIAL_REINFORCEMENTS_ARRIVED = 79`
|
||||
|
||||
**Implementation (TODO):**
|
||||
- When scripted flee triggers, create reinforcement units in defender's reserves
|
||||
- Units have `UnitStatus_RESERVE_UNIT` status
|
||||
- Reinforcements enter battle on subsequent turns
|
||||
|
||||
### Phase 5: Tutorial Popups
|
||||
|
||||
**Content Definitions** (in `TutorialContentDefinitions.cs`):
|
||||
- `tutorial_battle_started`: "Defend your province!"
|
||||
- `tutorial_enemy_fled`: "The enemy retreats!"
|
||||
- `tutorial_reinforcements_arrived`: "Allies have arrived!"
|
||||
- `tutorial_battle_victory`: Victory celebration
|
||||
|
||||
**Trigger Integration:**
|
||||
- `TutorialTriggerRegistry.cs`: Handles tutorial action types from `ActionResultView`
|
||||
- `ShardokGameController.cs`: Detects tutorial events and triggers popups
|
||||
|
||||
### Phase 6: Post-Battle Hero Joining
|
||||
|
||||
**Implementation (TODO):**
|
||||
- `ResolveBattleAction.scala`: Check if battle was tutorial and defender won
|
||||
- Create `TutorialHeroJoined` action results adding reinforcement heroes to player faction
|
||||
- Set appropriate loyalty, vigor, location for joined heroes
|
||||
|
||||
### Phase 7: Post-Battle Strategic Dialogue
|
||||
|
||||
After the battle ends and the player returns to the strategic map, narrative dialogues guide them through the next steps.
|
||||
|
||||
**Dialogue 1: Battle Aftermath** (trigger: `tutorial_battle_ended`)
|
||||
- Old Marek reflects on the close call
|
||||
- Tarn has vanished — rumors of sorcery or escape, no one knows
|
||||
- Captured lieutenants: they followed orders, not to blame for Tarn's madness
|
||||
- Player should try to recruit them or hold them
|
||||
- No instruction text needed — Handle Captured Heroes UI is self-explanatory
|
||||
|
||||
**Dialogue 2: Rebuild Support** (trigger: `tutorial_rebuild_support`)
|
||||
- Fires when available commands no longer include HandleCapturedHeroCommand
|
||||
- Marek explains the province needs support rebuilt after the battle
|
||||
- Engineers (John Ranil) can **Improve** the province — build infrastructure, develop economy
|
||||
- Paladins (Elena Fyar) can **Give Alms** — distribute food to win hearts
|
||||
- Instructions highlight **Improve** and **Give Alms** buttons
|
||||
- Goal: get Support to **40** before January for tax revenue
|
||||
|
||||
**Implementation:**
|
||||
- Dialogues defined in `tutorial_strategic.json`
|
||||
- `tutorial_battle_ended`: fire when tutorial battle is removed from `RunningShardokGameModels`
|
||||
- `tutorial_rebuild_support`: fire when available commands no longer include `HandleCapturedHeroCommand`
|
||||
- Register `ImproveButton` and `AlmsButton` as highlight targets in `TutorialTargetRegistry`
|
||||
|
||||
See `TUTORIAL_CONTENT.md` for full dialogue text.
|
||||
|
||||
---
|
||||
|
||||
## File Summary
|
||||
|
||||
### New Files
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `TutorialBattleController.hpp/cpp` | C++ scripted flee and reinforcement logic |
|
||||
| `NarrativeScreenController.cs` | Unity narrative screen display |
|
||||
|
||||
### Modified Files
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `tutorial_game_parameters.json` | Battle configuration and narrative content |
|
||||
| `game_state_view.proto` | Narrative screens field |
|
||||
| `game_parameters.proto` | Tutorial battle config |
|
||||
| `player_info.proto` | Tutorial battle flags for Shardok |
|
||||
| `action_type.proto` | Tutorial action types |
|
||||
| `NewGameCreation.scala` | Auto-start battle setup |
|
||||
| `EagleGameController.cs` | Narrative display integration |
|
||||
| `TutorialContentDefinitions.cs` | Battle tutorial content |
|
||||
| `TutorialTriggerRegistry.cs` | Tutorial event triggers |
|
||||
|
||||
---
|
||||
|
||||
## Remaining Implementation
|
||||
|
||||
### Disable Player Flee
|
||||
In `FleeCommandFactory.cpp`:
|
||||
- Check if tutorial mode is enabled
|
||||
- If defender, don't offer flee commands
|
||||
|
||||
### Reinforcement Units
|
||||
In `TutorialBattleController::ExecuteScriptedFlee()`:
|
||||
- Create `Unit` objects for Elena Fyar, Hedrick, The Boulder
|
||||
- Add to defender's reserve units
|
||||
- Generate `TUTORIAL_REINFORCEMENTS_ARRIVED` action
|
||||
|
||||
### Post-Battle Hero Joining
|
||||
In Eagle's battle resolution:
|
||||
- Detect tutorial battle completion
|
||||
- Create heroes in player's faction with proper stats
|
||||
- Generate notification action results
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] New user sees narrative screens before battle
|
||||
- [ ] Battle starts immediately after narratives (no strategic map)
|
||||
- [ ] Player has correct units (3 heroes, 3 battalions)
|
||||
- [ ] Attacker has correct units (3 heroes, 3 battalions)
|
||||
- [ ] Player cannot flee
|
||||
- [ ] Tarn flees after player loses 1 unit
|
||||
- [ ] Tarn flees after 5 rounds (if no units lost)
|
||||
- [ ] Reinforcements appear when Tarn flees
|
||||
- [ ] Tutorial popups appear at correct moments
|
||||
- [ ] Reinforcement heroes join faction after battle victory
|
||||
|
||||
---
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
### tutorial_game_parameters.json
|
||||
```json
|
||||
{
|
||||
"tutorialBattle": {
|
||||
"attackerFactionHead": "Ikhaan Tarn",
|
||||
"targetProvinceId": 14,
|
||||
"fleeAfterDefenderUnitsLost": 1,
|
||||
"fleeAfterRounds": 5,
|
||||
"reinforcements": ["Elena Fyar", "Hedrick", "The Boulder"]
|
||||
},
|
||||
"tutorialNarrativeScreens": [
|
||||
{ "title": "...", "bodyText": "...", "imagePath": "" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### TutorialBattleConfig Proto (Shardok)
|
||||
```protobuf
|
||||
message TutorialBattleConfig {
|
||||
bool enabled = 1;
|
||||
repeated TutorialEvent events = 2;
|
||||
repeated UnitInitialSize initial_sizes = 3; // For damage tracking
|
||||
}
|
||||
|
||||
message TutorialEvent {
|
||||
string event_id = 1;
|
||||
TutorialTrigger trigger = 2;
|
||||
TutorialAction action = 3;
|
||||
}
|
||||
|
||||
message TutorialTrigger {
|
||||
oneof trigger_type {
|
||||
RoundTrigger after_round = 1;
|
||||
UnitsLostTrigger units_lost = 2;
|
||||
DamageTakenTrigger damage_taken = 3;
|
||||
UnitKilledTrigger unit_killed = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message TutorialAction {
|
||||
oneof action_type {
|
||||
FleeAction flee = 1;
|
||||
ReinforcementsAction reinforcements = 2; // Uses CommonUnit
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,227 +0,0 @@
|
||||
# Tutorial Content Guide
|
||||
|
||||
This document defines all tutorial content. Edit this to refine the text, then update `TutorialContentDefinitions.cs` to match.
|
||||
|
||||
---
|
||||
|
||||
## Onboarding Sequence
|
||||
|
||||
Shown to first-time players. Guides them through the basics of strategic and tactical gameplay.
|
||||
|
||||
| Step | ID | Display | Trigger | Title | Description |
|
||||
|------|-----|---------|---------|-------|-------------|
|
||||
| 1 | `welcome` | Modal | Auto (game start) | Welcome to Eagle0 | Command your armies, recruit heroes, and conquer provinces in this turn-based strategy game.<br><br>Let's walk through the basics! |
|
||||
| 2 | `select_province` | Overlay | Completes on: `province_selected` | The Strategic Map | This is your kingdom. Each colored region is a province.<br><br>Tap a province you control (shown in your color) to see what you can do there. |
|
||||
| 3 | `province_panel` | Modal | Button click | Province Information | This panel shows province details: its name, terrain, any armies present, and the commands available to you.<br><br>Commands let you move troops, recruit heroes, and more. |
|
||||
| 4 | `try_march` | Overlay | Completes on: `command_issued` | Issue a Command | Try issuing a March command to move your army to an adjacent province.<br><br>Select a destination and confirm the order. |
|
||||
| 5 | `turn_cycle` | Modal | Button click | The Turn Cycle | Eagle0 uses simultaneous turns. All players give orders at the same time, then turns resolve together.<br><br>When all players are ready, the server processes everyone's commands and shows the results. |
|
||||
| 6 | `wait_for_battle` | Hidden | Completes on: `first_battle_available` | *(none)* | *(Invisible step - waits for a battle to become available)* |
|
||||
| 7 | `battle_intro` | Modal | Button click | Battle Time! | When armies collide, you'll fight tactical battles on a hex grid.<br><br>You command individual units - infantry, cavalry, archers, and heroes with special abilities. |
|
||||
| 8 | `enter_battle` | Overlay | Completes on: `battle_entered` | Enter the Battle | Tap the Battle button to enter tactical combat. |
|
||||
| 9 | `tactical_overview` | Modal | Button click | Tactical Combat | Each unit has movement points and attack power. Position your troops wisely!<br><br>Units attack adjacent enemies. Flanking (attacking from multiple sides) deals bonus damage. |
|
||||
| 10 | `move_unit` | Overlay | Completes on: `battle_action` | Move Your Units | Tap one of your units to select it, then tap a highlighted hex to move there.<br><br>Blue hexes show where you can move. |
|
||||
| 11 | `attack_enemy` | Overlay | Completes on: `battle_action` | Attack! | Move next to an enemy unit, then tap the enemy to attack.<br><br>Red highlights show valid attack targets. |
|
||||
| 12 | `end_turn` | Overlay | Completes on: `turn_ended` | End Your Turn | When you've moved all units or want to pass, tap End Turn.<br><br>The enemy will then take their turn. |
|
||||
| 13 | `complete` | Modal | Button click (no skip) | You're Ready! | You now know the basics of Eagle0!<br><br>Explore diplomacy, recruit powerful heroes, and conquer the realm. Good luck, commander! |
|
||||
|
||||
### Notes on Onboarding Flow
|
||||
|
||||
- Steps 1-5 cover strategic gameplay
|
||||
- Step 6 is invisible - just waits for a battle
|
||||
- Steps 7-12 cover tactical combat
|
||||
- Step 13 celebrates completion
|
||||
|
||||
**Questions to consider:**
|
||||
- Should we skip tactical tutorial if player skips to first battle themselves?
|
||||
- Should there be a "skip all" option visible from step 1?
|
||||
- Is the step order correct for typical first-game flow?
|
||||
|
||||
---
|
||||
|
||||
## Strategic Contextual Tutorials
|
||||
|
||||
Triggered when players encounter features for the first time.
|
||||
|
||||
### Diplomacy Introduction
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `diplomacy_intro` |
|
||||
| Trigger | `diplomacy_available` (diplomacy commands appear) |
|
||||
| Display | Modal |
|
||||
| Title | Diplomacy |
|
||||
| Description | You can negotiate with other factions!<br><br>Offer alliances, declare war, or propose tribute. Your diplomatic choices shape the realm. |
|
||||
|
||||
### Hero Recruitment
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `hero_recruitment` |
|
||||
| Trigger | `hero_recruitment_available` (free heroes detected) |
|
||||
| Display | Modal |
|
||||
| Title | Heroes Available |
|
||||
| Description | Free heroes wander the realm seeking a lord to serve.<br><br>Recruit them to lead your armies! Heroes have unique abilities and grow stronger with experience. |
|
||||
|
||||
### Weather Control
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `weather_control` |
|
||||
| Trigger | `weather_control_available` (weather command appears) |
|
||||
| Display | Overlay |
|
||||
| Title | Weather Magic |
|
||||
| Description | Your mages can influence the weather!<br><br>Rain slows movement, storms disrupt enemies, and clear skies speed your march. |
|
||||
|
||||
### Prisoner Management
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `prisoner_management` |
|
||||
| Trigger | `prisoner_command_issued` (player uses prisoner command) |
|
||||
| Display | Modal |
|
||||
| Title | Prisoners Captured |
|
||||
| Description | You've captured enemy soldiers!<br><br>You can ransom them for gold, recruit them into your army, or execute them as a warning. |
|
||||
|
||||
---
|
||||
|
||||
## Tactical Contextual Tutorials
|
||||
|
||||
Triggered during battles when players encounter spells, terrain, or abilities.
|
||||
|
||||
### Lightning Bolt Spell
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `spell_lightning` |
|
||||
| Trigger | `spell_lightning_available` |
|
||||
| Display | Tooltip |
|
||||
| Title | Lightning Bolt |
|
||||
| Description | Your mage can cast Lightning Bolt!<br><br>This spell strikes a single target for heavy damage. Great for eliminating key enemy units. |
|
||||
|
||||
### Meteor Strike Spell
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `spell_meteor` |
|
||||
| Trigger | `spell_meteor_available` |
|
||||
| Display | Modal |
|
||||
| Title | Meteor Strike |
|
||||
| Description | Meteor is a devastating area spell!<br><br>It takes a turn to cast: first select target, then it lands next turn. Plan ahead! |
|
||||
|
||||
### Holy Wave Spell
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `spell_holywave` |
|
||||
| Trigger | `spell_holywave_available` |
|
||||
| Display | Tooltip |
|
||||
| Title | Holy Wave |
|
||||
| Description | Holy Wave heals your units and damages undead!<br><br>Position your troops carefully to maximize its effect. |
|
||||
|
||||
### Raise Dead Spell
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `spell_raisedead` |
|
||||
| Trigger | `spell_raisedead_available` |
|
||||
| Display | Modal |
|
||||
| Title | Raise Dead |
|
||||
| Description | Dark magic can raise fallen soldiers as undead!<br><br>They fight for you, but beware - they may crumble if your necromancer falls. |
|
||||
|
||||
### Fire Terrain
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `terrain_fire` |
|
||||
| Trigger | `terrain_fire_encountered` (fire damage occurs) |
|
||||
| Display | Tooltip |
|
||||
| Title | Fire Hazard |
|
||||
| Description | Fire spreads across the battlefield!<br><br>Units in burning hexes take damage. Use fire to block enemy routes or avoid it yourself. |
|
||||
|
||||
### Water Crossing
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `terrain_water` |
|
||||
| Trigger | `terrain_water_encountered` (water crossing attempted) |
|
||||
| Display | Tooltip |
|
||||
| Title | Water Crossing |
|
||||
| Description | Units can cross shallow water, but it's risky.<br><br>Crossing takes extra movement and may fail. Some units swim better than others. |
|
||||
|
||||
### Cavalry Charge
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `ability_charge` |
|
||||
| Trigger | `ability_charge_available` |
|
||||
| Display | Overlay |
|
||||
| Title | Cavalry Charge |
|
||||
| Description | Your cavalry can Charge!<br><br>Charging deals bonus damage based on distance traveled. Use open terrain for maximum impact. |
|
||||
|
||||
---
|
||||
|
||||
## Post-Battle Dialogue (Narrative Dialogues)
|
||||
|
||||
These fire after the first tutorial battle ends and the player returns to the strategic map. They use the narrative dialogue system (`DialogueManager` / `tutorial_strategic.json`) rather than the modal tutorial system.
|
||||
|
||||
### Battle Aftermath
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `post_battle_aftermath` |
|
||||
| Trigger | `tutorial_battle_ended` (battle removed from RunningShardokGameModels) |
|
||||
| Speaker | Old Marek the Learned |
|
||||
| Panel Position | top |
|
||||
|
||||
**Dialogue (Marek):**
|
||||
> That was closer than I'd like to admit. We held — barely. But Tarn... Tarn is gone. Not retreated — *gone*. The men say he vanished from the field. Some claim sorcery. Others say he slipped away in the chaos. Whatever the truth, no one can find him.
|
||||
>
|
||||
> But we captured some of his lieutenants. These are soldiers, Sadar — they followed orders, same as we once did. They're not to blame for Tarn's madness. See if you can bring them to our cause, or at least hold them until they come around.
|
||||
|
||||
**Instructions:** *(none — Handle Captured Heroes UI is self-explanatory)*
|
||||
|
||||
### Rebuild Support
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| ID | `post_battle_rebuild` |
|
||||
| Trigger | `tutorial_rebuild_support` (fires when available commands no longer include HandleCapturedHeroCommand) |
|
||||
| Speaker | Old Marek the Learned |
|
||||
| Panel Position | top |
|
||||
|
||||
**Dialogue (Marek):**
|
||||
> Good. Now we need to rebuild our support here in Onmaa. The people are shaken — a battle on their doorstep will do that. We need them behind us if we're going to hold this province.
|
||||
>
|
||||
> Any of our heroes can develop the land or give alms to the people — nothing wins hearts faster than a full belly. Engineers like John Ranil are especially effective at improving a province, and paladins like Elena Fyar are the best at winning hearts. Either way, we need the people's support before the tax collectors come round in January.
|
||||
|
||||
**Instructions:** Use **Improve** and **Give Alms** to raise Support. You must have at least **40** support in the province by January in order to collect gold and food in taxes.
|
||||
|
||||
**Highlight targets:** `SupportField`
|
||||
|
||||
### Implementation Notes
|
||||
|
||||
- Both dialogues go in `tutorial_strategic.json`
|
||||
- Need new trigger events: `tutorial_battle_ended` and `tutorial_rebuild_support`
|
||||
- `tutorial_battle_ended` should fire when the tutorial battle is removed from `RunningShardokGameModels` in `EagleGameModel.ApplyGameStateViewDiff`
|
||||
- `tutorial_rebuild_support` should fire when available commands no longer include `HandleCapturedHeroCommand` (i.e. the captured heroes phase is done and the player has regular commands available)
|
||||
- The "rebuild" dialogue should highlight the Improve and Give Alms buttons in the command panel (requires registering them as highlight targets)
|
||||
|
||||
---
|
||||
|
||||
## Display Modes
|
||||
|
||||
| Mode | Description | Use For |
|
||||
|------|-------------|---------|
|
||||
| **Modal** | Full popup with dimmed background, blocks interaction | Important concepts, multi-paragraph explanations |
|
||||
| **Overlay** | Semi-transparent overlay, can highlight UI elements | Guiding player to interact with specific UI |
|
||||
| **Tooltip** | Small popup near target element | Quick tips, less important info |
|
||||
| **Hint** | Pulsing dot indicator only | Subtle suggestions |
|
||||
| **None** | Invisible, just waits for event | Transition steps |
|
||||
|
||||
---
|
||||
|
||||
## Adding New Tutorials
|
||||
|
||||
1. Add entry to this document
|
||||
2. Update `TutorialContentDefinitions.cs`:
|
||||
- For onboarding: add to `CreateOnboardingSequence()`
|
||||
- For contextual: add to `RegisterStrategicTutorials()` or `RegisterTacticalTutorials()`
|
||||
3. Ensure trigger event exists in `TutorialTriggerRegistry.cs`
|
||||
4. Test the flow
|
||||
|
||||
---
|
||||
|
||||
## Content Guidelines
|
||||
|
||||
- Keep descriptions to 2-3 short paragraphs max
|
||||
- Use `<br><br>` for paragraph breaks (renders as newlines in Unity)
|
||||
- Avoid jargon - explain game terms when first introduced
|
||||
- Be encouraging, not condescending
|
||||
- Focus on "what to do" not exhaustive "how it works"
|
||||
@@ -1,19 +0,0 @@
|
||||
# Two-Stage Animation Implementation Pattern
|
||||
|
||||
Actions with server-determined success/failure outcomes use a two-stage system:
|
||||
1. **Attempt phase**: Animation + sound plays immediately when the command is issued
|
||||
2. **Result phase**: A distinct success or failure animation + sound plays when the server responds
|
||||
|
||||
## Adding a New Two-Stage Action
|
||||
|
||||
To add distinct success/failure animations for an action (using `ExtinguishFire` as an example):
|
||||
|
||||
1. **AnimationType enum** (`ShardokGameController.cs`): Add `ExtinguishFireFailed`
|
||||
2. **AnimationTypeForAction()**: Map `ActionType.ExtinguishFireFailed` to new type
|
||||
3. **AttemptAnimationType()**: Map `ExtinguishFireFailed` back to `ExtinguishFire`
|
||||
4. **ActionTypeForSound()**: Map new type to `ActionType.ExtinguishFireFailed`
|
||||
5. **PlayAnimation()**: Add dispatch case calling the new animator method
|
||||
6. **Animator**: Add `AnimateExtinguishFailed()` method
|
||||
7. **SoundManager**: Ensure result sound is mapped (usually already done)
|
||||
|
||||
See FearAnimator and FleeAnimator for reference implementations.
|
||||
@@ -1,113 +0,0 @@
|
||||
# AI Quest Completion Behavior
|
||||
|
||||
This document describes which quests the AI attempts to complete proactively to recruit unaffiliated heroes.
|
||||
|
||||
## Overview
|
||||
|
||||
The AI attempts to complete quests via `FulfillQuestsCommandSelector`, which is invoked by `MidGameAIClient.chosenFulfillEasyQuestsCommand`. The AI only considers quests from unaffiliated heroes in provinces ruled by a faction leader.
|
||||
|
||||
## Quests the AI Actively Completes
|
||||
|
||||
### Diplomacy Quests
|
||||
|
||||
| Quest | Handler | Conditions |
|
||||
|-------|---------|------------|
|
||||
| `AllianceQuest` | `AllianceQuestCommandChooser` | Target faction must meet trust conditions for alliance and not already be in an alliance |
|
||||
| `TruceWithFactionQuest` | `TruceWithFactionQuestCommandChooser` | Target faction must meet trust conditions for truce |
|
||||
| `TruceCountQuest` | `TruceCountQuestCommandChooser` | Picks a random faction that meets trust conditions and isn't already in a truce/alliance |
|
||||
|
||||
### Resource Giving Quests
|
||||
|
||||
| Quest | Handler | Notes |
|
||||
|-------|---------|-------|
|
||||
| `AlmsToProvinceQuest` | `AlmsToProvinceQuestCommandChooser` | Gives food to the specified province |
|
||||
| `AlmsAcrossRealmQuest` | `AlmsAcrossRealmQuestCommandChooser` | Gives food from the province with the largest surplus |
|
||||
| `GiveToHeroesInProvinceQuest` | `GiveToHeroesInProvinceQuestCommandChooser` | Gives gold to the hero with the lowest loyalty in the specified province |
|
||||
| `GiveToHeroesAcrossRealmQuest` | `GiveToHeroesAcrossRealmQuestCommandChooser` | Gives gold from the province with the most gold available |
|
||||
|
||||
### Province Development Quests
|
||||
|
||||
| Quest | Handler | Notes |
|
||||
|-------|---------|-------|
|
||||
| `ImproveAgricultureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Agriculture type |
|
||||
| `ImproveEconomyQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Economy type |
|
||||
| `ImproveInfrastructureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Infrastructure type |
|
||||
|
||||
### Other Quests
|
||||
|
||||
| Quest | Handler | Conditions |
|
||||
|-------|---------|------------|
|
||||
| `DismissSpecificVassalQuest` | `DismissSpecificVassalCommandChooser` | Only if province has more than 2 heroes AND the unaffiliated hero's power >= target hero's power * `RequiredPowerMultiplierForDismiss` |
|
||||
|
||||
## Quests the AI Does Not Attempt to Complete
|
||||
|
||||
The following quests have no handler in `FulfillQuestsCommandSelector` and must be completed naturally through gameplay:
|
||||
|
||||
### Combat/Military Quests
|
||||
- `DefeatFactionQuest` - Defeat a specific faction
|
||||
- `GrandArmyQuest` - Accumulate a large number of troops
|
||||
- `UpgradeBattalionQuest` - Upgrade a battalion to minimum armament/training
|
||||
- `WinBattleOutnumberedQuest` - Win a battle while outnumbered
|
||||
- `WinBattlesQuest` - Win a number of battles
|
||||
- `RescueImprisonedLeaderQuest` - Rescue an imprisoned leader from another faction
|
||||
|
||||
### Expansion Quests
|
||||
- `ExpandToProvincesQuest` - Expand to control a certain number of provinces
|
||||
- `SpecificExpansionQuest` - Conquer a specific province
|
||||
- `BorderSecurityQuest` - Have troops in a border province
|
||||
|
||||
### Prisoner Quests
|
||||
- `ExecutePrisonerQuest` - Execute a specific prisoner
|
||||
- `ExilePrisonerQuest` - Exile a specific prisoner
|
||||
- `ReleasePrisonerQuest` - Release a specific prisoner
|
||||
- `ReturnPrisonerQuest` - Return a prisoner to their faction
|
||||
- `ReleaseAllPrisonersQuest` - Release all prisoners
|
||||
|
||||
### Province Order Quests
|
||||
- `DevelopProvincesQuest` - Maintain provinces in Develop order for months
|
||||
- `MobilizeProvincesQuest` - Maintain provinces in Mobilize order for months
|
||||
- `RestProvinceQuest` - Use the Rest command in a specific province
|
||||
|
||||
### Reconnaissance Quests
|
||||
- `ReconProvincesQuest` - Reconnoiter a number of provinces
|
||||
- `ReconSpecificProvincesQuest` - Reconnoiter specific provinces
|
||||
|
||||
### Economic Quests
|
||||
- `TotalDevelopmentQuest` - Achieve total development level in a province
|
||||
- `WealthQuest` - Accumulate gold and food
|
||||
- `SpendOnFeastsQuest` - Spend gold on feasts
|
||||
- `SendSuppliesQuest` - Send food to a specific province
|
||||
- `RepairDevastationQuest` - Repair devastation
|
||||
|
||||
### Special Event Quests
|
||||
- `SuppressRiotByForceQuest` - Suppress a riot by force
|
||||
- `FightBeastsAloneQuest` - Fight beasts alone
|
||||
- `StartBlizzardQuest` - Start a blizzard in a province
|
||||
- `StartEpidemicQuest` - Start an epidemic in a province
|
||||
- `ApprehendOutlawQuest` - Apprehend an outlaw hero
|
||||
|
||||
### Miscellaneous
|
||||
- `BattalionDiversityQuest` - Have diverse battalion types
|
||||
- `SwearBrotherhoodWithHeroQuest` - Swear brotherhood with a specific hero
|
||||
- `BetrayAllyQuest` - Betray an allied faction
|
||||
|
||||
## Implementation Details
|
||||
|
||||
The quest completion logic is located in:
|
||||
- `FulfillQuestsCommandSelector.scala` - Main entry point, iterates through choosers
|
||||
- `quest_command_selectors/` - Individual quest handlers
|
||||
|
||||
Each chooser extends either:
|
||||
- `QuestCommandChooser` - For quests requiring randomness
|
||||
- `DeterministicQuestCommandChooser` - For quests with deterministic command selection
|
||||
|
||||
The AI prioritizes quests in the order they appear in `FulfillQuestsCommandSelector.choosers`:
|
||||
1. Alliance
|
||||
2. TruceWithFaction
|
||||
3. Improve (Agriculture/Economy/Infrastructure)
|
||||
4. AlmsToProvince
|
||||
5. GiveToHeroesInProvince
|
||||
6. AlmsAcrossRealm
|
||||
7. GiveToHeroesAcrossRealm
|
||||
8. TruceCount
|
||||
9. DismissSpecificVassal
|
||||
@@ -1,230 +0,0 @@
|
||||
# Plan: Eliminate Battalion Backstory LLM Updates
|
||||
|
||||
## Goal
|
||||
|
||||
Dramatically reduce LLM request volume by eliminating battalion backstory updates. Battalions will retain:
|
||||
- An **initial backstory** (generated once when created)
|
||||
- A **history of events** (structured data, not LLM-generated prose)
|
||||
|
||||
The history will be fed into other LLM updates (hero backstories, chronicle) but will no longer be regenerated into prose for each battalion.
|
||||
|
||||
## Current System Overview
|
||||
|
||||
### Data Flow
|
||||
```
|
||||
Battalion Created → Initial Backstory LLM Request → Text stored
|
||||
↓
|
||||
Battalion participates in events → Events accumulated → Update LLM Request → New text version
|
||||
↓
|
||||
BattalionView.backstoryTextId → Client displays on hover
|
||||
```
|
||||
|
||||
### Event Types (8 total)
|
||||
Events are structured data already captured in `EventForBattalionBackstory`:
|
||||
- OrganizedTroops, SuppressedBeasts, ApprehendedOutlaws, FoughtInBattle
|
||||
- SuppressedRiot, Trained, Armed, SurvivedStarvation
|
||||
|
||||
Each event has date, province, hero, and event-specific details.
|
||||
|
||||
### Key Files
|
||||
|
||||
| Component | File |
|
||||
|-----------|------|
|
||||
| Unity Display | `Assets/Eagle/HeroesAndBattalionsPanelController.cs` |
|
||||
| View Proto | `views/battalion_view.proto` |
|
||||
| View Filter | `BattalionViewFilter.scala` |
|
||||
| Update Action | `BattalionBackstoryUpdateAction.scala` |
|
||||
| Update Generator | `BattalionBackstoryUpdateActionGenerator.scala` |
|
||||
| Update Prompt | `BattalionBackstoryUpdatePromptGenerator.scala` |
|
||||
| Event-to-Text | `BattalionBackstoryUpdatePromptGenerator.textForEvent()` |
|
||||
| BattalionDescriptions | `BattalionDescriptions.scala` (used in other LLM prompts) |
|
||||
|
||||
---
|
||||
|
||||
## Stage 1: Stop Displaying Battalion Backstories in Client
|
||||
|
||||
**Goal:** Remove the UI that shows battalion backstory text on hover.
|
||||
|
||||
### Files to Modify
|
||||
|
||||
**`Assets/Eagle/HeroesAndBattalionsPanelController.cs`**
|
||||
- In `BattalionLongHoverRowChanged()` (lines 143-169):
|
||||
- Remove or comment out the backstory text population
|
||||
- Keep the header/name display, remove `battalionPopupPanelBackstory.TextId = ...`
|
||||
|
||||
**Unity Scene/Prefab**
|
||||
- Hide or remove the `battalionPopupPanelBackstory` UI element
|
||||
|
||||
### Verification
|
||||
- [ ] Battalion hover popup no longer shows backstory text
|
||||
- [ ] No errors when hovering over battalions
|
||||
- [ ] Other battalion info (name, type, size) still displays
|
||||
|
||||
---
|
||||
|
||||
## Stage 2: Stop Populating Backstory in BattalionView
|
||||
|
||||
**Goal:** Stop sending backstory text ID to the client.
|
||||
|
||||
### Files to Modify
|
||||
|
||||
**`BattalionViewFilter.scala`**
|
||||
- In `filteredBattalionViewBelongingToPlayer()`:
|
||||
- Set `backstoryTextId = ""` instead of `battalion.backstoryTextId`
|
||||
- In `limitedBattalionView()`:
|
||||
- Same change
|
||||
|
||||
### Verification
|
||||
- [ ] BattalionView messages have empty backstoryTextId
|
||||
- [ ] Client handles empty backstoryTextId gracefully (Stage 1 should have removed the display)
|
||||
|
||||
---
|
||||
|
||||
## Stage 3: Remove backstory_text_id from BattalionView Proto
|
||||
|
||||
**Goal:** Clean up the proto schema.
|
||||
|
||||
### Files to Modify
|
||||
|
||||
**`views/battalion_view.proto`**
|
||||
- Remove field: `string backstory_text_id = 7;`
|
||||
- Mark field 7 as reserved to prevent reuse
|
||||
|
||||
**`Assets/Eagle/HeroesAndBattalionsPanelController.cs`**
|
||||
- Remove any remaining references to `BackstoryTextId`
|
||||
|
||||
**`BattalionViewFilter.scala`**
|
||||
- Remove `backstoryTextId` from view construction
|
||||
|
||||
### Verification
|
||||
- [ ] Proto compiles
|
||||
- [ ] C# client compiles without BackstoryTextId references
|
||||
- [ ] Full test suite passes
|
||||
|
||||
---
|
||||
|
||||
## Stage 4: Replace Backstory Usage in BattalionDescriptions
|
||||
|
||||
**Goal:** When other LLM prompts need battalion context, use initial backstory + structured history instead of the latest LLM-generated prose.
|
||||
|
||||
### Current Usage in `BattalionDescriptions.scala`
|
||||
|
||||
```scala
|
||||
def backstoryText(): TextGenerationResult =
|
||||
battalion.backstoryVersions.lastOption
|
||||
.map(v => clientTextStore.getText(v.textId))
|
||||
.getOrElse(TextGenerationSuccess(""))
|
||||
|
||||
def fullDescription(): String =
|
||||
s"${battalion.name} is $description. $backstoryText"
|
||||
```
|
||||
|
||||
### New Approach
|
||||
|
||||
Create `historyDescription()` that:
|
||||
1. Gets initial backstory (first version's text, if available)
|
||||
2. Appends structured event history using the same `textForEvent()` logic from `BattalionBackstoryUpdatePromptGenerator`
|
||||
|
||||
```scala
|
||||
def historyDescription(): String = {
|
||||
val initialBackstory = battalion.backstoryVersions.headOption
|
||||
.map(v => clientTextStore.getText(v.textId))
|
||||
.collect { case TextGenerationSuccess(text) => text }
|
||||
.getOrElse("")
|
||||
|
||||
val eventHistory = battalion.backstoryEvents
|
||||
.map(textForEvent) // Reuse existing event-to-text conversion
|
||||
.mkString(" ")
|
||||
|
||||
if eventHistory.isEmpty then initialBackstory
|
||||
else s"$initialBackstory $eventHistory"
|
||||
}
|
||||
```
|
||||
|
||||
### Files to Modify
|
||||
|
||||
**`BattalionDescriptions.scala`**
|
||||
- Add `historyDescription()` method
|
||||
- Update `fullDescription()` to use `historyDescription()` instead of `backstoryText()`
|
||||
- Keep `textForEvent()` logic (move from `BattalionBackstoryUpdatePromptGenerator` if needed)
|
||||
|
||||
**Move/refactor `textForEvent()`**
|
||||
- Currently in `BattalionBackstoryUpdatePromptGenerator.scala` (lines 78-262)
|
||||
- Move to `BattalionDescriptions.scala` or a shared utility
|
||||
- This converts events to human-readable text without LLM
|
||||
|
||||
### Verification
|
||||
- [ ] Other LLM prompts that use `BattalionDescriptions` still get meaningful context
|
||||
- [ ] Chronicle updates include battalion history
|
||||
- [ ] Hero backstory updates include relevant battalion history
|
||||
|
||||
---
|
||||
|
||||
## Stage 5: Stop Generating Battalion Backstory Updates
|
||||
|
||||
**Goal:** Remove the LLM request generation entirely.
|
||||
|
||||
### Files to Modify
|
||||
|
||||
**`BattalionBackstoryUpdateActionGenerator.scala`**
|
||||
- Return empty Vector instead of generating actions
|
||||
- Or delete the file entirely
|
||||
|
||||
**`BattalionBackstoryUpdateAction.scala`**
|
||||
- Delete or mark as deprecated
|
||||
|
||||
**`BattalionBackstoryUpdatePromptGenerator.scala`**
|
||||
- Delete or keep only `textForEvent()` if moved
|
||||
|
||||
**`LlmResolver.scala`**
|
||||
- Remove case for `BattalionBackstoryUpdateRequest`
|
||||
- Optionally throw error if somehow called
|
||||
|
||||
**`EndPlayerCommandsPhaseAction.scala`** (and other callers)
|
||||
- Find where `BattalionBackstoryUpdateActionGenerator` is called
|
||||
- Remove those calls
|
||||
|
||||
### Files to Search for Callers
|
||||
```bash
|
||||
grep -r "BattalionBackstoryUpdateAction" src/
|
||||
```
|
||||
|
||||
### Verification
|
||||
- [ ] No `BattalionBackstoryUpdateRequest` LLM requests generated
|
||||
- [ ] Battalions still accumulate events (for history description)
|
||||
- [ ] Full test suite passes
|
||||
- [ ] Game runs without errors
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
### Keep or Remove Initial Backstory Generation?
|
||||
The initial backstory (`BattalionInitialBackstoryRequest`) is generated once per battalion. Options:
|
||||
1. **Keep it** - Low volume, provides flavor text for history
|
||||
2. **Remove it** - Use a template-based approach instead
|
||||
|
||||
Recommendation: Keep for now, evaluate later based on volume.
|
||||
|
||||
### Event History Cleanup
|
||||
Events currently accumulate forever on battalions. Consider:
|
||||
- Capping event history length
|
||||
- Summarizing old events
|
||||
- Only keeping "significant" events
|
||||
|
||||
### Affected Tests
|
||||
Search for tests that verify backstory update behavior:
|
||||
```bash
|
||||
bazel query 'tests(//src/test/scala/...) intersect rdeps(//src/test/scala/..., //src/main/scala/net/eagle0/eagle/library/actions/impl/action:battalion_backstory_update_action)'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary of LLM Request Reduction
|
||||
|
||||
| Request Type | Current Volume | After Change |
|
||||
|--------------|---------------|--------------|
|
||||
| BattalionInitialBackstoryRequest | 1 per battalion | 1 per battalion (unchanged) |
|
||||
| BattalionBackstoryUpdateRequest | ~N per battalion per game | **0** |
|
||||
|
||||
For a typical game with ~50 battalions and ~20 rounds, this could eliminate **hundreds** of LLM requests.
|
||||
@@ -1,93 +0,0 @@
|
||||
# Province Event 3D Effects
|
||||
|
||||
Ideas for replacing 2D shader-based province event effects with 3D particle systems.
|
||||
|
||||
## Current State
|
||||
|
||||
| Event | Effect Type | Notes |
|
||||
|-------|-------------|-------|
|
||||
| Festival | 3D particles | Complete |
|
||||
| Epidemic | 3D particles | Complete (rising skulls + green haze) |
|
||||
| Blizzard | 2D shader | Not visible on unowned (white) provinces |
|
||||
| Drought | 2D shader | |
|
||||
| Flood | 2D shader | |
|
||||
| Beasts | None | Tricky - can be any animal or human |
|
||||
|
||||
## Proposed 3D Effects
|
||||
|
||||
### Blizzard
|
||||
|
||||
- Falling snowflakes with slight blue tint (ensures visibility on white backgrounds)
|
||||
- Diagonal wind-blown particles to convey harsh weather
|
||||
- Swirling vortex effect near ground level
|
||||
- Frosty sparkle/glitter particles for magical winter feel
|
||||
|
||||
### Drought
|
||||
|
||||
- Rising dust/sand particles (tan/brown coloring)
|
||||
- Small dust devils (spinning particle columns)
|
||||
- Shimmering golden particles rising to suggest heat distortion
|
||||
- Dried leaves/debris blowing across the province
|
||||
|
||||
### Flood
|
||||
|
||||
- Rain falling (blue-tinted streaks or droplets)
|
||||
- Water splashes/ripples rising from ground
|
||||
- Mist/spray particles hovering low
|
||||
- Floating debris particles (leaves, sticks)
|
||||
|
||||
### Beasts
|
||||
|
||||
This is challenging since "beasts" can represent any dangerous creature. Universal approaches:
|
||||
|
||||
- **Circling vultures/crows overhead** - Predators attract scavengers; works for any beast type and is visually distinctive
|
||||
- Dust clouds suggesting movement at province edges
|
||||
- Glowing eyes appearing/disappearing in shadows
|
||||
- Stylized claw/scratch mark effects
|
||||
- Paw prints materializing on the ground
|
||||
- Red "danger" aura pulsing from ground level
|
||||
|
||||
The vultures/crows approach is recommended as the most universal solution.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
Controllers have been created for all 4 event types:
|
||||
- `ProvinceBlizzardController.cs` - ready, needs prefab
|
||||
- `ProvinceDroughtController.cs` - ready, needs prefab
|
||||
- `ProvinceFloodController.cs` - ready, needs prefab
|
||||
- `ProvinceBeastsController.cs` - ready, needs prefab
|
||||
|
||||
### Prefabs Needed
|
||||
|
||||
Create these prefabs in `Assets/Eagle/Effects/`:
|
||||
|
||||
1. **BlizzardEffect.prefab**
|
||||
- Particle System with falling snowflakes
|
||||
- Blue-tinted particles for visibility on white
|
||||
- Diagonal velocity for wind effect
|
||||
- RectTransform for UI positioning
|
||||
|
||||
2. **DroughtEffect.prefab**
|
||||
- Particle System with rising dust particles
|
||||
- Tan/brown coloring
|
||||
- Upward velocity
|
||||
- Optional spinning sub-emitters for dust devils
|
||||
|
||||
3. **FloodEffect.prefab**
|
||||
- Particle System with falling rain streaks
|
||||
- Blue tint
|
||||
- Fast downward velocity
|
||||
- Optional splash sub-emitter
|
||||
|
||||
4. **BeastsEffect.prefab**
|
||||
- Particle System with bird silhouettes
|
||||
- Circular orbit motion around center
|
||||
- Black/dark particles
|
||||
- Could use a crow/vulture sprite texture
|
||||
|
||||
### Scene Setup
|
||||
|
||||
Wire up in `Gameplay.unity`:
|
||||
1. Add controller components to the Map GameObject
|
||||
2. Assign `mapContainer`, `centroidsJson` (shared with other controllers)
|
||||
3. Assign the effect prefab for each controller
|
||||
Binary file not shown.
Vendored
-5
@@ -1,5 +0,0 @@
|
||||
# Export BUILD files for use with http_archive build_file attribute
|
||||
exports_files([
|
||||
"BUILD.gtl",
|
||||
"BUILD.llvm_mingw",
|
||||
])
|
||||
|
||||
Vendored
-48
@@ -1,48 +0,0 @@
|
||||
# LLVM MinGW toolchain for Windows cross-compilation
|
||||
# Provides x86_64-w64-mingw32 target compiler and libraries
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
filegroup(
|
||||
name = "all_files",
|
||||
srcs = glob(["**/*"]),
|
||||
)
|
||||
|
||||
# Compiler binaries
|
||||
filegroup(
|
||||
name = "compiler_files",
|
||||
srcs = glob([
|
||||
"bin/x86_64-w64-mingw32-*",
|
||||
"bin/clang*",
|
||||
"bin/llvm-*",
|
||||
"bin/lld*",
|
||||
]),
|
||||
)
|
||||
|
||||
# Windows x86_64 sysroot (headers and libraries)
|
||||
filegroup(
|
||||
name = "windows_x86_64_sysroot",
|
||||
srcs = glob([
|
||||
"x86_64-w64-mingw32/**/*",
|
||||
"generic-w64-mingw32/include/**/*",
|
||||
]),
|
||||
)
|
||||
|
||||
# All library files needed for linking
|
||||
filegroup(
|
||||
name = "linker_files",
|
||||
srcs = glob([
|
||||
"bin/x86_64-w64-mingw32-*",
|
||||
"bin/lld*",
|
||||
"bin/ld.lld*",
|
||||
"lib/**/*",
|
||||
"x86_64-w64-mingw32/lib/**/*",
|
||||
]),
|
||||
)
|
||||
|
||||
# The main C compiler wrapper script path for CGO
|
||||
# CGO needs CC to point to the cross-compiler
|
||||
exports_files([
|
||||
"bin/x86_64-w64-mingw32-clang",
|
||||
"bin/x86_64-w64-mingw32-clang++",
|
||||
])
|
||||
@@ -9,11 +9,6 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/config v1.28.10
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.51
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6
|
||||
golang.org/x/sys v0.30.0
|
||||
google.golang.org/grpc v1.68.0
|
||||
google.golang.org/protobuf v1.36.3
|
||||
)
|
||||
|
||||
|
||||
@@ -34,22 +34,12 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.33.6 h1:VwhTrsTuVn52an4mXx29PqRzs2Dv
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.6/go.mod h1:+8h7PZb3yY5ftmVLD7ocEoE98hdc8PoKS0H3wfx1dlc=
|
||||
github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
|
||||
github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 h1:VQpB2SpK88C6B5lPHTuSZKb2Qee1QWwiFlC5CKY4AW0=
|
||||
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6/go.mod h1:yE65LFCeWf4kyWD5re+h4XNvOHJEXOCOuJZ4v8l5sgk=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0=
|
||||
google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA=
|
||||
google.golang.org/protobuf v1.26.0-rc.1 h1:7QnIQpGRHE5RnLKnESfDoxm2dTapTZua5a0kS0A+VXQ=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
|
||||
|
||||
+170
-674
File diff suppressed because it is too large
Load Diff
@@ -1,312 +0,0 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
# Allow large request bodies for game uploads (default is 1MB)
|
||||
client_max_body_size 50M;
|
||||
|
||||
# Logging
|
||||
log_format grpc_json escape=json '{'
|
||||
'"time":"$time_iso8601",'
|
||||
'"client":"$remote_addr",'
|
||||
'"uri":"$uri",'
|
||||
'"status":$status,'
|
||||
'"grpc_status":"$sent_http_grpc_status",'
|
||||
'"request_time":$request_time,'
|
||||
'"upstream_time":"$upstream_response_time"'
|
||||
'}';
|
||||
|
||||
access_log /var/log/nginx/access.log grpc_json;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
|
||||
# Rate limiting zone
|
||||
limit_req_zone $binary_remote_addr zone=grpc_limit:10m rate=100r/s;
|
||||
|
||||
# Docker DNS resolver - re-resolve hostnames every 10s
|
||||
# This prevents stale IP caching when containers restart
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
# Eagle backend - blue-green deployment with variable-based routing
|
||||
# Uses a variable so nginx only resolves the configured backend (not all backends).
|
||||
# This allows nginx to start/reload even when the inactive backend is stopped.
|
||||
# The deploy script updates this map, then recreates nginx.
|
||||
map $host $eagle_backend {
|
||||
default "eagle-blue:40032";
|
||||
}
|
||||
|
||||
# HTTP server for Let's Encrypt challenge and redirect
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name prod.eagle0.net eagle0.net;
|
||||
|
||||
# Let's Encrypt challenge
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
# Redirect all other HTTP to HTTPS
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS server for gRPC
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
http2 on;
|
||||
server_name prod.eagle0.net eagle0.net;
|
||||
|
||||
# SSL certificates (managed by certbot)
|
||||
ssl_certificate /etc/letsencrypt/live/prod.eagle0.net/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/prod.eagle0.net/privkey.pem;
|
||||
|
||||
# SSL configuration
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# gRPC proxy for Eagle service
|
||||
location /net.eagle0.eagle.api.Eagle {
|
||||
# Rate limiting
|
||||
limit_req zone=grpc_limit burst=50 nodelay;
|
||||
|
||||
# gRPC proxy - uses variable for blue-green deployment
|
||||
grpc_pass grpc://$eagle_backend;
|
||||
|
||||
# Timeouts for long-running streams
|
||||
grpc_read_timeout 1200s;
|
||||
grpc_send_timeout 1200s;
|
||||
grpc_socket_keepalive on;
|
||||
|
||||
# Error handling
|
||||
error_page 502 = /error502grpc;
|
||||
}
|
||||
|
||||
# gRPC proxy for Auth service (routes to Go auth service, not Eagle)
|
||||
location /net.eagle0.eagle.api.auth.Auth {
|
||||
# Rate limiting
|
||||
limit_req zone=grpc_limit burst=50 nodelay;
|
||||
|
||||
# Route to auth service directly (not through Eagle)
|
||||
set $auth_backend "auth:40033";
|
||||
grpc_pass grpc://$auth_backend;
|
||||
|
||||
# Timeouts
|
||||
grpc_read_timeout 30s;
|
||||
grpc_send_timeout 30s;
|
||||
|
||||
# Error handling
|
||||
error_page 502 = /error502grpc;
|
||||
}
|
||||
|
||||
# OAuth callback endpoint (proxied to Go auth service)
|
||||
location /oauth/callback {
|
||||
proxy_pass http://auth:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Apple OAuth callback (Apple uses POST with form_post response mode)
|
||||
location /oauth/apple/callback {
|
||||
proxy_pass http://auth:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Steam OAuth callback (Steam uses OpenID 2.0)
|
||||
location /oauth/steam/callback {
|
||||
proxy_pass http://auth:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Invitation landing page (proxied to Go auth service)
|
||||
location /invite/ {
|
||||
proxy_pass http://auth:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Credits/attributions page (proxied to Go auth service)
|
||||
location /credits {
|
||||
proxy_pass http://auth:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "OK\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# gRPC error handling
|
||||
location = /error502grpc {
|
||||
internal;
|
||||
default_type application/grpc;
|
||||
add_header grpc-status 14;
|
||||
add_header grpc-message "unavailable";
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS server for Go Auth service (port 40033)
|
||||
# Clients connect here directly for OAuth RPCs in Phase 2
|
||||
server {
|
||||
listen 40033 ssl;
|
||||
listen [::]:40033 ssl;
|
||||
http2 on;
|
||||
server_name prod.eagle0.net;
|
||||
|
||||
# SSL certificates (same as main server)
|
||||
ssl_certificate /etc/letsencrypt/live/prod.eagle0.net/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/prod.eagle0.net/privkey.pem;
|
||||
|
||||
# SSL configuration
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
# gRPC proxy for Auth service
|
||||
# Uses variable-based resolution so nginx can start even if auth isn't ready yet
|
||||
# DNS is cached by the resolver directive (valid=10s)
|
||||
location /net.eagle0.eagle.api.auth.Auth {
|
||||
# Rate limiting
|
||||
limit_req zone=grpc_limit burst=50 nodelay;
|
||||
|
||||
# Dynamic upstream resolution (doesn't block nginx startup)
|
||||
set $auth_backend "auth:40033";
|
||||
grpc_pass grpc://$auth_backend;
|
||||
|
||||
# Timeouts
|
||||
grpc_read_timeout 30s;
|
||||
grpc_send_timeout 30s;
|
||||
|
||||
# Error handling
|
||||
error_page 502 = /error502grpc;
|
||||
}
|
||||
|
||||
# gRPC proxy for Admin service
|
||||
location /net.eagle0.eagle.api.admin.Admin {
|
||||
# Rate limiting
|
||||
limit_req zone=grpc_limit burst=50 nodelay;
|
||||
|
||||
# Dynamic upstream resolution (doesn't block nginx startup)
|
||||
set $auth_backend "auth:40033";
|
||||
grpc_pass grpc://$auth_backend;
|
||||
|
||||
# Timeouts
|
||||
grpc_read_timeout 30s;
|
||||
grpc_send_timeout 30s;
|
||||
|
||||
# Error handling
|
||||
error_page 502 = /error502grpc;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "OK\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# gRPC error handling
|
||||
location = /error502grpc {
|
||||
internal;
|
||||
default_type application/grpc;
|
||||
add_header grpc-status 14;
|
||||
add_header grpc-message "unavailable";
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTP server for Admin Console (Let's Encrypt + redirect)
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name admin.prod.eagle0.net admin.eagle0.net;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS server for Admin Console
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name admin.prod.eagle0.net admin.eagle0.net;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/admin.eagle0.net/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/admin.eagle0.net/privkey.pem;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
location / {
|
||||
proxy_pass http://admin:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTP server for Accounts Console (Let's Encrypt + redirect)
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name accounts.prod.eagle0.net accounts.eagle0.net;
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS server for Accounts Console (user self-service portal)
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name accounts.prod.eagle0.net accounts.eagle0.net;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/accounts.eagle0.net/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/accounts.eagle0.net/privkey.pem;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_session_timeout 1d;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
|
||||
location / {
|
||||
proxy_pass http://admin:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
# Scala 3 Migration: Reflection Issues Found
|
||||
|
||||
This document catalogs all reflection-related problems discovered during the Scala 2.13.16 → Scala 3.7.2 migration of the Eagle0 codebase.
|
||||
|
||||
## Summary
|
||||
|
||||
The migration revealed several categories of reflection issues that needed to be addressed for Scala 3 compatibility:
|
||||
|
||||
1. **Scala 2 Runtime Reflection API** - No longer available in Scala 3
|
||||
2. **Settings System Reflection** - Custom reflection for loading settings singletons
|
||||
3. **json4s Automatic Case Class Extraction** - Uses reflection that fails with Scala 3 metaprogramming classes
|
||||
4. **ScalaTest Exception Handling** - Syntax changes affecting exception variable binding
|
||||
|
||||
## 1. Scala 2 Runtime Reflection (FIXED)
|
||||
|
||||
### Issue
|
||||
Tests using `scala.reflect.runtime.universe` fail because this reflection API doesn't exist in Scala 3.
|
||||
|
||||
### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/test/scala/net/eagle0/eagle/library/actions/types/ActionResultTypesTest.scala`
|
||||
|
||||
### Error
|
||||
```scala
|
||||
import scala.reflect.runtime.universe // Not available in Scala 3
|
||||
```
|
||||
|
||||
### Solution Applied
|
||||
**Deleted the test entirely** as it was redundant. The test was verifying that auto-generated Scala objects (created by Bazel from proto enum values) matched their source proto values - something already guaranteed by the build system. Since the objects are generated directly from the proto definitions, this test provided no value.
|
||||
|
||||
**Files deleted:**
|
||||
- `src/test/scala/net/eagle0/eagle/library/actions/types/ActionResultTypesTest.scala`
|
||||
|
||||
## 2. Settings System Reflection (FIXED)
|
||||
|
||||
### Issue
|
||||
Custom `SettingsLoader` class used reflection to access Scala object singletons, but the reflection pattern changed between Scala 2 and Scala 3.
|
||||
|
||||
### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/library/settings/loaders/SettingsLoader.scala`
|
||||
|
||||
### Error
|
||||
```
|
||||
java.lang.NoSuchMethodException: net.eagle0.eagle.library.settings.ApprehendOutlawVigorCost$.MODULE$
|
||||
```
|
||||
|
||||
### Root Cause
|
||||
In Scala 2, singleton objects are accessed via `ClassName$.MODULE$()`, but in Scala 3, they're accessed directly via `ClassName$` field. Additionally, `scala.reflect.runtime.universe` is not available in Scala 3.
|
||||
|
||||
### Solution Applied
|
||||
**Completely eliminated reflection** by auto-generating the entire `SettingsLoader.scala` file from BUILD.bazel definitions:
|
||||
|
||||
1. **Created generator**: `src/main/go/net/eagle0/build/settings_loader_generator/settings_loader_generator.go` - parses BUILD.bazel and generates complete SettingsLoader.scala with pattern matching for all 272 settings
|
||||
|
||||
2. **Added genrule**: In `src/main/scala/net/eagle0/eagle/library/settings/loaders/BUILD.bazel`:
|
||||
```python
|
||||
genrule(
|
||||
name = "settings_loader_src",
|
||||
srcs = ["//src/main/scala/net/eagle0/eagle/library/settings:BUILD.bazel"],
|
||||
outs = ["SettingsLoader.scala"],
|
||||
cmd = "$(location //src/main/go/net/eagle0/build/settings_loader_generator) $(location //src/main/scala/net/eagle0/eagle/library/settings:BUILD.bazel) > $@",
|
||||
tools = ["//src/main/go/net/eagle0/build/settings_loader_generator"],
|
||||
)
|
||||
```
|
||||
|
||||
3. **Result**: SettingsLoader now uses compile-time pattern matching instead of reflection:
|
||||
```scala
|
||||
private def settingObjectForKey(key: String): Any = key match {
|
||||
case "ActionVigorCost" => ActionVigorCost
|
||||
case "BaseFoodBuyPrice" => BaseFoodBuyPrice
|
||||
// ... all 272 settings auto-generated
|
||||
case _ => throw NoSuchSettingException(key)
|
||||
}
|
||||
```
|
||||
|
||||
### Benefits
|
||||
- **No reflection** - Completely Scala 3 compatible
|
||||
- **Maintainable** - New settings automatically included when added to BUILD.bazel
|
||||
- **Performance** - Pattern matching is faster than reflection
|
||||
- **Type-safe** - Compile-time checking of all settings
|
||||
|
||||
## 3. json4s Reflection Issues (MULTIPLE LOCATIONS)
|
||||
|
||||
### 3.1 EagleServiceImpl JSON Serialization (FIXED)
|
||||
|
||||
#### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/service/EagleServiceImpl.scala`
|
||||
|
||||
#### Error
|
||||
```
|
||||
java.lang.NoClassDefFoundError: scala/quoted/staging/package$
|
||||
```
|
||||
|
||||
#### Root Cause
|
||||
json4s automatic case class serialization uses reflection that tries to access Scala 3 metaprogramming classes (`scala.quoted.staging.package$`) which aren't available at runtime.
|
||||
|
||||
#### Solution Applied
|
||||
Replaced automatic json4s serialization with ScalaPB's built-in JSON support:
|
||||
|
||||
```scala
|
||||
// Old (reflection-based):
|
||||
// implicit val formats: DefaultFormats.type = DefaultFormats
|
||||
// write(actionResultView)
|
||||
|
||||
// New (ScalaPB JSON support):
|
||||
import scalapb.json4s.JsonFormat
|
||||
JsonFormat.toJsonString(actionResultView.toProto)
|
||||
```
|
||||
|
||||
### 3.2 ShardokMapInfo JSON Parsing (FIXED)
|
||||
|
||||
#### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/library/util/ShardokMapInfo.scala` (Line 44)
|
||||
|
||||
#### Error
|
||||
```
|
||||
java.lang.NoClassDefFoundError: scala/quoted/staging/package$
|
||||
at org.json4s.reflect.ScalaSigReader$.readConstructor(ScalaSigReader.scala:42)
|
||||
```
|
||||
|
||||
#### Root Cause
|
||||
The line `val extracted = parsedJson.extract[List[ShardokMapInfo]]` uses json4s automatic case class extraction which relies on reflection.
|
||||
|
||||
#### Solution Applied
|
||||
Replaced automatic extraction with manual JSON parsing:
|
||||
|
||||
```scala
|
||||
// OLD (reflection-based):
|
||||
val extracted = parsedJson.extract[List[ShardokMapInfo]]
|
||||
|
||||
// NEW (manual parsing, no reflection):
|
||||
val extracted = parsedJson match {
|
||||
case JArray(items) => items.map { item =>
|
||||
val name = (item \ "name").extract[String]
|
||||
val castleCount = (item \ "castleCount").extract[Int]
|
||||
val positions = (item \ "positions").extract[Map[Int, Int]]
|
||||
ShardokMapInfo(name, castleCount, positions)
|
||||
}
|
||||
case _ => throw new Exception("Expected JSON array for map info")
|
||||
}
|
||||
```
|
||||
|
||||
#### Testing
|
||||
The fix was verified - `attack_command_chooser_test` now passes successfully.
|
||||
|
||||
### 3.3 HeroNameFetcher JSON Parsing (FIXED)
|
||||
|
||||
#### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/library/util/hero_name_fetcher/HeroNameFetcher.scala`
|
||||
|
||||
#### Issue
|
||||
Case class extraction `parsedJson.extract[ResponseBody]` uses reflection that may fail in Scala 3.
|
||||
|
||||
#### Solution Applied
|
||||
Replaced automatic case class extraction with manual JSON parsing:
|
||||
|
||||
```scala
|
||||
// OLD (reflection-based):
|
||||
val parsedJson = json.parse(src.getLines().mkString)
|
||||
parsedJson.extract[ResponseBody]
|
||||
|
||||
// NEW (manual parsing, no reflection):
|
||||
parsedJson \ "names" match {
|
||||
case JArray(nameArray) =>
|
||||
nameArray.map { nameObj =>
|
||||
val id = (nameObj \ "id").extract[String]
|
||||
val name = (nameObj \ "name").extract[String]
|
||||
NameResponse(id, name)
|
||||
}.toVector
|
||||
case _ => throw new Exception("Expected 'names' array in response")
|
||||
}
|
||||
```
|
||||
|
||||
#### Testing
|
||||
The fix was verified - HeroNameFetcher now builds successfully without reflection.
|
||||
|
||||
### 3.4 Other json4s Usage Analysis
|
||||
|
||||
#### Files with json4s extraction:
|
||||
- **✅ SAFE**: OpenAI/Claude Services - Only extract simple types (`String`, `Int`) - no reflection
|
||||
- **✅ FIXED**: `HeroNameFetcher.scala` - Replaced `extract[ResponseBody]` with manual parsing (no reflection)
|
||||
- **⚠️ POTENTIAL ISSUES** (not currently causing failures but should be monitored):
|
||||
- `JsonUtils.scala`: `extract[Map[String, Vector[String]]]` - complex type extraction
|
||||
- `HexMapJsonUtils.scala`: `extract[List[JObject]]` - may be problematic
|
||||
|
||||
#### Recommendation
|
||||
Apply the same manual parsing pattern to remaining case class extractions if they cause runtime failures during Scala 3 migration.
|
||||
|
||||
## 4. ScalaTest Exception Handling Syntax (FIXED)
|
||||
|
||||
### Issue
|
||||
Scala 3 changed how exception variables are bound in ScalaTest's `the[Exception] thrownBy {...}` construct.
|
||||
|
||||
### Files Affected
|
||||
**70+ test files** across the codebase using exception testing patterns.
|
||||
|
||||
### Error Pattern
|
||||
```
|
||||
Not found: ex
|
||||
```
|
||||
|
||||
### Root Cause
|
||||
In Scala 2: `the[Exception] thrownBy { ... }` automatically creates an `ex` variable.
|
||||
In Scala 3: The exception variable must be explicitly bound.
|
||||
|
||||
### Solution Applied
|
||||
Added explicit variable binding across all affected test files:
|
||||
|
||||
```scala
|
||||
// Old Scala 2 syntax:
|
||||
the[EagleCommandException] thrownBy {
|
||||
// test code
|
||||
}
|
||||
ex.getMessage shouldBe "expected message"
|
||||
|
||||
// New Scala 3 syntax:
|
||||
val ex = the[EagleCommandException] thrownBy {
|
||||
// test code
|
||||
}
|
||||
ex.getMessage shouldBe "expected message"
|
||||
```
|
||||
|
||||
### Script Used
|
||||
Created and ran a systematic fix script that processed 70+ files:
|
||||
|
||||
```bash
|
||||
# Pattern to find and fix exception handling
|
||||
find . -name "*.scala" -exec sed -i '' 's/the\[\([^]]*\)\] thrownBy {/val ex = the[\1] thrownBy {/g' {} \;
|
||||
```
|
||||
|
||||
## 5. ScalaTest Import Changes (FIXED)
|
||||
|
||||
### Issue
|
||||
Scala 3 requires different imports for ScalaTest matchers.
|
||||
|
||||
### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/test/scala/net/eagle0/eagle/library/actions/impl/command/DeclineQuestCommandTest.scala`
|
||||
|
||||
### Error
|
||||
```
|
||||
value convertToAnyShouldWrapper is not a member of object org.scalatest.matchers.should.Matchers
|
||||
```
|
||||
|
||||
### Solution Applied
|
||||
Changed from specific imports to wildcard import:
|
||||
|
||||
```scala
|
||||
// Old:
|
||||
import org.scalatest.matchers.should.Matchers.{convertToAnyShouldWrapper, the}
|
||||
|
||||
// New:
|
||||
import org.scalatest.matchers.should.Matchers.*
|
||||
```
|
||||
|
||||
## 6. Mock Framework Issues (FIXED)
|
||||
|
||||
### Issue
|
||||
ScalaMock had type inference issues with Scala 3 for classes with constructor parameters.
|
||||
|
||||
### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/test/scala/net/eagle0/eagle/library/EngineImplTest.scala`
|
||||
|
||||
### Error
|
||||
```
|
||||
Found: Vector
|
||||
Required: Vector[net.eagle0.eagle.library.util.hero_generator.hero_with_name.HeroWithName]
|
||||
```
|
||||
|
||||
### Root Cause
|
||||
Mock framework couldn't properly infer types for `mock[HeroGenerator]` where `HeroGenerator` has constructor parameters.
|
||||
|
||||
### Solution Applied
|
||||
The user updated to a newer ScalaMock version that fixed this issue, plus added some missing Bazel dependencies:
|
||||
|
||||
```scala
|
||||
// Also needed to add missing dependency:
|
||||
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution"
|
||||
```
|
||||
|
||||
## Migration Status
|
||||
|
||||
### ✅ COMPLETED
|
||||
- [x] Scala 2 runtime reflection removal
|
||||
- [x] Settings system reflection compatibility
|
||||
- [x] EagleServiceImpl json4s → ScalaPB JSON
|
||||
- [x] ScalaTest exception handling syntax (70+ files)
|
||||
- [x] ScalaTest import changes
|
||||
- [x] Mock framework issues (via ScalaMock update)
|
||||
- [x] All test compilation issues resolved
|
||||
|
||||
### ⚠️ REMAINING
|
||||
- [ ] **Potential json4s case class extractions** - May cause runtime failures (JsonUtils, HexMapJsonUtils) - currently no test failures reported
|
||||
|
||||
### 📊 PROGRESS
|
||||
- **Tests passing**: All identified runtime failures resolved
|
||||
- **Build failures**: 0 (all tests now compile)
|
||||
- **Runtime failures**: 0 (critical ShardokMapInfo issue resolved)
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **✅ COMPLETED**: ShardokMapInfo json4s reflection issue resolved with manual parsing
|
||||
2. **Monitor remaining json4s usage**: Watch for runtime failures in HeroNameFetcher, JsonUtils, and HexMapJsonUtils during full Scala 3 migration
|
||||
3. **Consider ScalaPB for new JSON needs**: For new functionality, prefer ScalaPB's JSON support to avoid reflection entirely
|
||||
4. **Apply manual parsing pattern**: If other json4s case class extractions cause runtime failures, use the same manual parsing approach demonstrated in ShardokMapInfo
|
||||
|
||||
## Key Learnings
|
||||
|
||||
- **Scala 3 reflection changes**: Major differences in singleton object access patterns
|
||||
- **json4s compatibility**: Automatic case class extraction doesn't work well with Scala 3 metaprogramming
|
||||
- **ScalaPB advantage**: Using ScalaPB's JSON support avoids reflection issues entirely
|
||||
- **Systematic approach**: Many issues followed patterns that could be fixed with scripts across multiple files
|
||||
@@ -3,11 +3,7 @@
|
||||
set -euxo pipefail
|
||||
|
||||
/bin/echo "building darwin bundle"
|
||||
bazel build --config=mactools @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle
|
||||
ZIP_LOCATION=$(bazel cquery --config=mactools --output=files @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle 2>/dev/null)
|
||||
/usr/bin/unzip -o $ZIP_LOCATION -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
|
||||
bazel build --noincompatible_enable_cc_toolchain_resolution @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle
|
||||
/usr/bin/unzip -o bazel-bin/external/net_eagle0_unity_godice/darwin/framework/DarwinGodiceBundle.zip -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
|
||||
|
||||
/usr/bin/plutil -convert xml1 src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/DarwinGodiceBundle.bundle/Contents/Info.plist
|
||||
|
||||
/bin/echo "building sparkle plugin"
|
||||
./scripts/build_sparkle_plugin.sh
|
||||
|
||||
@@ -5,9 +5,8 @@ set -euxo pipefail
|
||||
/bin/echo "build plugins"
|
||||
|
||||
/bin/echo "building darwin bundle"
|
||||
bazel build --config=mactools @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle
|
||||
ZIP_LOCATION=$(bazel cquery --config=mactools --output=files @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle 2>/dev/null)
|
||||
/usr/bin/unzip -o $ZIP_LOCATION -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
|
||||
bazel build --noincompatible_enable_cc_toolchain_resolution @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle
|
||||
/usr/bin/unzip -o bazel-bin/external/net_eagle0_unity_godice/darwin/framework/DarwinGodiceBundle.zip -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
|
||||
|
||||
/usr/bin/plutil -convert xml1 src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/DarwinGodiceBundle.bundle/Contents/Info.plist
|
||||
|
||||
|
||||
+4
-64
@@ -2,67 +2,7 @@
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
UNITY_ROOT="$REPO_ROOT/src/main/csharp/net/eagle0/clients/unity/eagle0"
|
||||
OUTPUT_DIR="$UNITY_ROOT/Assets/GeneratedProtos"
|
||||
|
||||
# All C# protobuf generation targets
|
||||
TARGETS=(
|
||||
"//src/main/protobuf/net/eagle0/common:common_csharp_proto_srcs"
|
||||
"//src/main/protobuf/net/eagle0/shardok/storage:storage_csharp_proto_srcs"
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:common_csharp_proto_srcs"
|
||||
"//src/main/protobuf/net/eagle0/shardok/api:api_csharp_proto_srcs"
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:common_csharp_proto_srcs"
|
||||
"//src/main/protobuf/net/eagle0/eagle/views:views_csharp_proto_srcs"
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:api_csharp_proto_srcs"
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:api_csharp_grpc_srcs"
|
||||
"//src/main/protobuf/net/eagle0/eagle/api/command/util:util_csharp_proto_srcs"
|
||||
)
|
||||
|
||||
# Subdirectory for each target (avoids filename collisions across packages)
|
||||
SUBDIRS=(
|
||||
"net/eagle0/common"
|
||||
"net/eagle0/shardok/storage"
|
||||
"net/eagle0/shardok/common"
|
||||
"net/eagle0/shardok/api"
|
||||
"net/eagle0/eagle/common"
|
||||
"net/eagle0/eagle/views"
|
||||
"net/eagle0/eagle/api"
|
||||
"net/eagle0/eagle/api"
|
||||
"net/eagle0/eagle/api/command/util"
|
||||
)
|
||||
|
||||
/bin/echo "Building C# protobuf sources..."
|
||||
bazel build "${TARGETS[@]}"
|
||||
|
||||
/bin/echo "Syncing generated .cs files to $OUTPUT_DIR..."
|
||||
|
||||
# Stage new files in a temp directory, then rsync to preserve timestamps
|
||||
# on unchanged files. This avoids triggering Unity reimport when protos
|
||||
# haven't changed, saving ~7 minutes of script recompilation.
|
||||
STAGING_DIR=$(mktemp -d)
|
||||
trap "rm -rf '$STAGING_DIR'" EXIT
|
||||
|
||||
for i in "${!TARGETS[@]}"; do
|
||||
target="${TARGETS[$i]}"
|
||||
subdir="${SUBDIRS[$i]}"
|
||||
|
||||
# Convert target label to bazel-bin path
|
||||
# //src/main/protobuf/net/eagle0/common:common_csharp_proto_srcs
|
||||
# -> bazel-bin/src/main/protobuf/net/eagle0/common/common_csharp_proto_srcs
|
||||
target_path="${target#//}"
|
||||
pkg="${target_path%%:*}"
|
||||
name="${target_path##*:}"
|
||||
bin_dir="$REPO_ROOT/bazel-bin/$pkg/$name"
|
||||
|
||||
dest_dir="$STAGING_DIR/$subdir"
|
||||
mkdir -p "$dest_dir"
|
||||
find "$bin_dir" -name "*.cs" -exec cp {} "$dest_dir/" \;
|
||||
done
|
||||
|
||||
# Sync only changed files and delete removed ones; --checksum compares
|
||||
# content not timestamps so unchanged files keep their original mtime.
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
rsync -rc --delete "$STAGING_DIR/" "$OUTPUT_DIR/"
|
||||
|
||||
/bin/echo "Done. Generated C# proto sources in $OUTPUT_DIR"
|
||||
/bin/echo "build protos"
|
||||
mkdir -p src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/Eagle0Protos/
|
||||
dotnet build src/main/csharp/net/eagle0/clients/unity/eagle0/protos/protos.csproj \
|
||||
-o src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/Eagle0Protos/
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build the SparklePlugin native library for Unity using Bazel
|
||||
#
|
||||
# Usage: build_sparkle_plugin.sh [output_dir]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
OUTPUT_DIR="${1:-$PROJECT_ROOT/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/macOS}"
|
||||
|
||||
echo "=== Building SparklePlugin with Bazel ==="
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
bazel build //src/main/objc/net/eagle0/clients/unity/sparkle:SparklePlugin
|
||||
|
||||
# Get the zip path from bazel
|
||||
ZIP_PATH=$(bazel cquery --output=files //src/main/objc/net/eagle0/clients/unity/sparkle:SparklePlugin 2>/dev/null)
|
||||
|
||||
echo "=== Extracting SparklePlugin.bundle ==="
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
rm -rf "$OUTPUT_DIR/SparklePlugin.bundle"
|
||||
unzip -o "$ZIP_PATH" -d "$OUTPUT_DIR/"
|
||||
|
||||
# Convert Info.plist from binary to XML format (Unity requires XML)
|
||||
/usr/bin/plutil -convert xml1 "$OUTPUT_DIR/SparklePlugin.bundle/Contents/Info.plist"
|
||||
|
||||
echo "=== SparklePlugin built successfully ==="
|
||||
ls -la "$OUTPUT_DIR/SparklePlugin.bundle/"
|
||||
@@ -1,150 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Check BUILD.bazel dependency constraints
|
||||
# This script enforces architectural boundaries in the codebase.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/check_build_deps.sh # Check all rules
|
||||
# ./scripts/check_build_deps.sh --ci # CI mode (fail on any violation)
|
||||
# ./scripts/check_build_deps.sh --count # Just count current violations (for tracking progress)
|
||||
# ./scripts/check_build_deps.sh --strict # Same as --ci (strict enforcement)
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
MODE="${1:-check}"
|
||||
EXIT_CODE=0
|
||||
|
||||
# Cache for expensive bazel query results
|
||||
LIBRARY_DEPS_CACHE=""
|
||||
MAIN_DEPS_CACHE=""
|
||||
|
||||
# Get deps of library/ (cached)
|
||||
get_library_deps() {
|
||||
if [ -z "$LIBRARY_DEPS_CACHE" ]; then
|
||||
LIBRARY_DEPS_CACHE=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...)' 2>/dev/null || true)
|
||||
fi
|
||||
echo "$LIBRARY_DEPS_CACHE"
|
||||
}
|
||||
|
||||
# Get deps of src/main (cached)
|
||||
get_main_deps() {
|
||||
if [ -z "$MAIN_DEPS_CACHE" ]; then
|
||||
MAIN_DEPS_CACHE=$(bazel query 'deps(//src/main/...)' 2>/dev/null || true)
|
||||
fi
|
||||
echo "$MAIN_DEPS_CACHE"
|
||||
}
|
||||
|
||||
# Rule 1: src/main should not depend on src/test
|
||||
check_main_depends_on_test() {
|
||||
echo -e "${YELLOW}Checking: src/main should not depend on src/test...${NC}"
|
||||
|
||||
violations=$(get_main_deps | grep "^//src/test/" || true)
|
||||
|
||||
if [ -n "$violations" ]; then
|
||||
echo -e "${RED}VIOLATION: src/main depends on src/test:${NC}"
|
||||
echo "$violations"
|
||||
return 1
|
||||
else
|
||||
echo -e "${GREEN}✓ No violations${NC}"
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# Rule 2: library/ should not depend on Scala proto types
|
||||
# C++/Go proto deps are allowed (they're build-time deps for map generation tools)
|
||||
check_library_depends_on_scala_proto() {
|
||||
echo -e "${YELLOW}Checking: library/ should not depend on Scala proto types...${NC}"
|
||||
|
||||
violations=$(get_library_deps | grep "^//src/main/protobuf/.*_scala_proto$" || true)
|
||||
if [ -z "$violations" ]; then
|
||||
count=0
|
||||
else
|
||||
count=$(echo "$violations" | grep -c "^//" || true)
|
||||
fi
|
||||
|
||||
if [ "$count" -gt 0 ]; then
|
||||
echo -e "${RED}VIOLATION: Found $count Scala proto dependencies in library/:${NC}"
|
||||
echo "$violations"
|
||||
return 1
|
||||
else
|
||||
echo -e "${GREEN}✓ No Scala proto dependencies in library/${NC}"
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# Rule 3: library/ should not depend on proto_converters
|
||||
# Proto conversions should happen at service boundaries, not in library code
|
||||
check_library_depends_on_proto_converters() {
|
||||
echo -e "${YELLOW}Checking: library/ should not depend on proto_converters...${NC}"
|
||||
|
||||
violations=$(get_library_deps | grep "^//src/main/scala/net/eagle0/eagle/model/proto_converters/" || true)
|
||||
|
||||
if [ -n "$violations" ]; then
|
||||
count=$(echo "$violations" | wc -l | tr -d ' ')
|
||||
echo -e "${RED}VIOLATION: library/ depends on $count proto_converters targets:${NC}"
|
||||
echo "$violations"
|
||||
echo ""
|
||||
echo "Proto conversions should happen at service boundaries (ShardokInterfaceGrpcClient,"
|
||||
echo "EagleServiceImpl, etc.), not in library code."
|
||||
return 1
|
||||
else
|
||||
echo -e "${GREEN}✓ No proto_converters dependencies in library/${NC}"
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# Count proto deps for informational purposes
|
||||
count_proto_deps() {
|
||||
echo -e "${YELLOW}=== Proto dependency counts ===${NC}"
|
||||
|
||||
library_deps=$(get_library_deps)
|
||||
scala_proto_results=$(echo "$library_deps" | grep "^//src/main/protobuf/.*_scala_proto$" || true)
|
||||
if [ -z "$scala_proto_results" ]; then
|
||||
scala_proto_count=0
|
||||
else
|
||||
scala_proto_count=$(echo "$scala_proto_results" | wc -l | tr -d ' ')
|
||||
fi
|
||||
echo "library/ Scala proto deps: $scala_proto_count"
|
||||
|
||||
# C++/Go proto deps are expected (map generation tools)
|
||||
all_proto_count=$(echo "$library_deps" | grep -c "^//src/main/protobuf/" || echo "0")
|
||||
echo "library/ all proto deps (includes C++/Go build tools): $all_proto_count"
|
||||
}
|
||||
|
||||
echo "=== BUILD.bazel Dependency Check ==="
|
||||
echo ""
|
||||
|
||||
case "$MODE" in
|
||||
--count)
|
||||
count_proto_deps
|
||||
;;
|
||||
--ci|--strict)
|
||||
check_main_depends_on_test || EXIT_CODE=1
|
||||
check_library_depends_on_scala_proto || EXIT_CODE=1
|
||||
check_library_depends_on_proto_converters || EXIT_CODE=1
|
||||
;;
|
||||
*)
|
||||
check_main_depends_on_test || EXIT_CODE=1
|
||||
check_library_depends_on_scala_proto || EXIT_CODE=1
|
||||
check_library_depends_on_proto_converters || EXIT_CODE=1
|
||||
echo ""
|
||||
count_proto_deps
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
echo -e "${GREEN}=== All checks passed ===${NC}"
|
||||
else
|
||||
echo -e "${RED}=== Some checks failed ===${NC}"
|
||||
fi
|
||||
|
||||
exit $EXIT_CODE
|
||||
@@ -1,141 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Code sign a macOS .app bundle for distribution
|
||||
# Usage: codesign_mac_app.sh <app_path> [entitlements_path]
|
||||
#
|
||||
# Environment variables:
|
||||
# SIGNING_IDENTITY - The signing identity (default: "Developer ID Application")
|
||||
# KEYCHAIN_PASSWORD - Password to unlock the keychain (optional, CI only)
|
||||
# KEYCHAIN_NAME - Name of the keychain containing the signing certificate (optional, CI only)
|
||||
# When set, looks for certificate in this specific keychain.
|
||||
# When not set, searches all keychains (local dev mode).
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
APP_PATH="$1"
|
||||
ENTITLEMENTS_PATH="${2:-}"
|
||||
SIGNING_IDENTITY="${SIGNING_IDENTITY:-Developer ID Application}"
|
||||
# KEYCHAIN_NAME is set by CI workflow - don't set a default here so we can detect if we're in CI
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
echo "ERROR: App not found at $APP_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Unlock keychain if password and keychain name are provided (CI environment)
|
||||
if [ -n "${KEYCHAIN_PASSWORD:-}" ] && [ -n "${KEYCHAIN_NAME:-}" ]; then
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" || true
|
||||
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
|
||||
find "$APP_PATH" -name "*.dylib" -print0 | while IFS= read -r -d '' item; do
|
||||
echo "Signing dylib: $item"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
done
|
||||
|
||||
# Sign all bundles (plugins)
|
||||
find "$APP_PATH" -name "*.bundle" -print0 | while IFS= read -r -d '' item; do
|
||||
echo "Signing bundle: $item"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
done
|
||||
|
||||
# Sign XPC services (but skip ones inside Sparkle.framework - they're already signed)
|
||||
find "$APP_PATH" -name "*.xpc" -print0 | while IFS= read -r -d '' item; do
|
||||
if [[ "$item" == *"Sparkle.framework"* ]]; then
|
||||
echo "Skipping Sparkle XPC service (pre-signed): $item"
|
||||
continue
|
||||
fi
|
||||
echo "Signing XPC service: $item"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
done
|
||||
|
||||
# Sign nested apps (but skip ones inside Sparkle.framework - they're already signed)
|
||||
find "$APP_PATH" -path "*/Frameworks/*.app" -print0 | while IFS= read -r -d '' item; do
|
||||
if [[ "$item" == *"Sparkle.framework"* ]]; then
|
||||
echo "Skipping Sparkle nested app (pre-signed): $item"
|
||||
continue
|
||||
fi
|
||||
echo "Signing nested app: $item"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
done
|
||||
|
||||
# Sign standalone executables inside frameworks (but skip Sparkle.framework internals)
|
||||
find "$APP_PATH" -path "*/Frameworks/*/Versions/*/Autoupdate" -type f -print0 | while IFS= read -r -d '' item; do
|
||||
if [[ "$item" == *"Sparkle.framework"* ]]; then
|
||||
echo "Skipping Sparkle executable (pre-signed): $item"
|
||||
continue
|
||||
fi
|
||||
echo "Signing executable: $item"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
done
|
||||
|
||||
# Sign all frameworks (after their contents are signed)
|
||||
# Use --deep for Sparkle.framework to handle its XPC services
|
||||
find "$APP_PATH" -name "*.framework" -print0 | while IFS= read -r -d '' item; do
|
||||
if [[ "$item" == *"Sparkle.framework" ]]; then
|
||||
echo "Signing Sparkle framework with --deep: $item"
|
||||
codesign --deep --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
else
|
||||
echo "Signing framework: $item"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "=== Signing main app bundle ==="
|
||||
|
||||
if [ -n "$ENTITLEMENTS_PATH" ] && [ -f "$ENTITLEMENTS_PATH" ]; then
|
||||
echo "Using entitlements: $ENTITLEMENTS_PATH"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--entitlements "$ENTITLEMENTS_PATH" \
|
||||
--sign "$SIGNING_IDENTITY" "$APP_PATH"
|
||||
else
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$APP_PATH"
|
||||
fi
|
||||
|
||||
echo "=== Verifying signature ==="
|
||||
codesign --verify --verbose=4 "$APP_PATH"
|
||||
|
||||
echo "=== Checking Gatekeeper assessment ==="
|
||||
spctl --assess --type exec -v "$APP_PATH" || echo "Note: Gatekeeper may reject until notarized"
|
||||
|
||||
echo "Code signing complete: $APP_PATH"
|
||||
@@ -1,402 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Blue-Green Deployment Script for Eagle Server
|
||||
#
|
||||
# This script performs a zero-downtime deployment with state consistency:
|
||||
# 1. Create .deployment_in_progress marker (signals deployment started)
|
||||
# 2. Start the staging instance (green) with new image
|
||||
# 3. Run warmup/smoke tests against staging (warms JIT)
|
||||
# 4. Switch nginx to staging (zero downtime - users immediately route to staging)
|
||||
# 5. Stop the active instance (blue) - blocks until flush completes
|
||||
# 6. Create .flush_complete marker (signals disk state is fresh)
|
||||
#
|
||||
# The flush marker coordination ensures green never serves stale game data:
|
||||
# - When users reconnect to green and trigger lazy-load, the code checks for markers
|
||||
# - If .deployment_in_progress exists, lazy-load WAITS for .flush_complete
|
||||
# - Once blue's flush completes and marker is created, lazy-load proceeds with fresh data
|
||||
#
|
||||
# Key insight: nginx switches to green BEFORE blue stops, achieving zero downtime.
|
||||
# Users who trigger lazy-load during blue's shutdown will wait for the flush marker.
|
||||
#
|
||||
# Usage: ./deploy-blue-green.sh [NEW_IMAGE_TAG]
|
||||
#
|
||||
# Example:
|
||||
# ./deploy-blue-green.sh latest
|
||||
# ./deploy-blue-green.sh sha-abc123
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP_DIR="${APP_DIR:-/opt/eagle0}"
|
||||
NGINX_CONF="${APP_DIR}/nginx/nginx.conf"
|
||||
COMPOSE_FILE="${APP_DIR}/docker-compose.prod.yml"
|
||||
WARMUP_SCRIPT="${SCRIPT_DIR}/warmup-eagle.sh"
|
||||
SAVES_DIR="${APP_DIR}/saves"
|
||||
FLUSH_MARKER="${SAVES_DIR}/.flush_complete"
|
||||
DEPLOYMENT_IN_PROGRESS="${SAVES_DIR}/.deployment_in_progress"
|
||||
ACTIVE_INSTANCE_FILE="${APP_DIR}/.active-instance"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
# Marker file operations use docker exec because saves directory is owned by root (Docker).
|
||||
# We run commands inside a container that has the saves directory mounted.
|
||||
create_deployment_marker() {
|
||||
local deploy_id=$1
|
||||
local container=$2 # Container to use for file operations
|
||||
docker exec "${container}" rm -f /app/saves/.flush_complete
|
||||
docker exec "${container}" sh -c "echo '${deploy_id}' > /app/saves/.deployment_in_progress"
|
||||
}
|
||||
|
||||
create_flush_marker() {
|
||||
local deploy_id=$1
|
||||
local container=$2 # Container to use for file operations
|
||||
docker exec "${container}" sh -c "echo '${deploy_id}' > /app/saves/.flush_complete"
|
||||
docker exec "${container}" rm -f /app/saves/.deployment_in_progress
|
||||
}
|
||||
|
||||
cleanup_markers_on_failure() {
|
||||
local container=$1 # Container to use for file operations
|
||||
docker exec "${container}" rm -f /app/saves/.deployment_in_progress 2>/dev/null || true
|
||||
docker exec "${container}" touch /app/saves/.flush_complete 2>/dev/null || true
|
||||
}
|
||||
|
||||
remove_stale_deployment_marker() {
|
||||
# Try any running eagle container
|
||||
local container
|
||||
container=$(docker ps --filter "name=eagle-" --format "{{.Names}}" | head -1)
|
||||
if [ -n "${container}" ]; then
|
||||
docker exec "${container}" rm -f /app/saves/.deployment_in_progress 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
# Determine which instance is currently running (not from nginx config)
|
||||
get_running_instance() {
|
||||
local blue_running green_running
|
||||
blue_running=$(docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null || echo "false")
|
||||
green_running=$(docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null || echo "false")
|
||||
|
||||
if [ "$blue_running" = "true" ] && [ "$green_running" = "true" ]; then
|
||||
# Both running - use nginx config to determine primary
|
||||
if grep -q "server eagle-blue:40032;" "${NGINX_CONF}" | head -1 | grep -qv backup; then
|
||||
echo "blue"
|
||||
else
|
||||
echo "green"
|
||||
fi
|
||||
elif [ "$blue_running" = "true" ]; then
|
||||
echo "blue"
|
||||
elif [ "$green_running" = "true" ]; then
|
||||
echo "green"
|
||||
else
|
||||
# Neither running - default to blue (first deploy or recovery)
|
||||
echo "none"
|
||||
fi
|
||||
}
|
||||
|
||||
# Pull image with retry using crane (handles OCI/Docker digest mismatch)
|
||||
pull_with_retry() {
|
||||
local image=$1
|
||||
local max_attempts=${2:-3}
|
||||
local attempt=1
|
||||
|
||||
# Skip pull if image already exists locally (e.g., CI already pulled it)
|
||||
if docker image inspect "${image}" &>/dev/null; then
|
||||
log_info "Image ${image} already exists locally, skipping pull"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Use crane if available (handles OCI format correctly)
|
||||
if [ -x "${APP_DIR}/crane" ]; then
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
log_info "Pulling image with crane (attempt ${attempt}/${max_attempts})..."
|
||||
if "${APP_DIR}/crane" pull "${image}" /tmp/image.tar && docker load -i /tmp/image.tar; then
|
||||
rm -f /tmp/image.tar
|
||||
log_info "Image pulled and loaded successfully"
|
||||
return 0
|
||||
fi
|
||||
rm -f /tmp/image.tar
|
||||
log_warn "Pull failed, retrying in 5 seconds..."
|
||||
sleep 5
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
else
|
||||
# Fallback to docker pull if crane not available
|
||||
log_warn "crane not found at ${APP_DIR}/crane, falling back to docker pull"
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
log_info "Pulling image (attempt ${attempt}/${max_attempts})..."
|
||||
if docker pull "${image}"; then
|
||||
log_info "Image pulled successfully"
|
||||
return 0
|
||||
fi
|
||||
log_warn "Pull failed, retrying in 5 seconds..."
|
||||
sleep 5
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
fi
|
||||
log_error "Failed to pull image after ${max_attempts} attempts"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Wait for a container to be healthy
|
||||
wait_for_healthy() {
|
||||
local container=$1
|
||||
local max_attempts=${2:-60}
|
||||
local attempt=1
|
||||
|
||||
log_info "Waiting for ${container} to become healthy..."
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
health=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "unknown")
|
||||
if [ "$health" = "healthy" ]; then
|
||||
log_info "${container} is healthy"
|
||||
return 0
|
||||
fi
|
||||
echo -n "."
|
||||
sleep 2
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
echo ""
|
||||
log_error "${container} did not become healthy after $((max_attempts * 2)) seconds"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Main deployment logic
|
||||
main() {
|
||||
local new_tag="${1:-latest}"
|
||||
local registry="registry.digitalocean.com/eagle0/eagle-server"
|
||||
local new_image="${registry}:${new_tag}"
|
||||
|
||||
# Generate deployment ID for log correlation with server logs
|
||||
local deploy_id
|
||||
deploy_id=$(date +%s)
|
||||
local deploy_start_time=$deploy_id
|
||||
|
||||
log_info "========================================="
|
||||
log_info "Starting blue-green deployment"
|
||||
log_info "Deployment ID: ${deploy_id}"
|
||||
log_info "New image: ${new_image}"
|
||||
log_info "========================================="
|
||||
|
||||
cd "${APP_DIR}"
|
||||
|
||||
# Determine current active instance (need this before creating marker)
|
||||
local active=$(get_running_instance)
|
||||
local staging
|
||||
if [ "$active" = "blue" ] || [ "$active" = "none" ]; then
|
||||
staging="green"
|
||||
active="blue" # Normalize "none" to "blue" for first deploy
|
||||
else
|
||||
staging="blue"
|
||||
fi
|
||||
|
||||
# Step 1: Signal deployment in progress
|
||||
log_info "[DEPLOY:${deploy_id}] Step 1: Signaling deployment in progress..."
|
||||
# Use active container for marker operations (it's the one currently running)
|
||||
if [ "$active" != "none" ] && docker ps --filter "name=eagle-${active}" --format "{{.Names}}" | grep -q .; then
|
||||
create_deployment_marker "${deploy_id}" "eagle-${active}"
|
||||
log_info "[DEPLOY:${deploy_id}] Deployment marker created via eagle-${active}"
|
||||
else
|
||||
log_warn "[DEPLOY:${deploy_id}] No running container to create marker (first deploy?)"
|
||||
fi
|
||||
|
||||
# Pull the new image (with retry for intermittent registry issues)
|
||||
if ! pull_with_retry "${new_image}" 3; then
|
||||
log_error "Failed to pull new image, aborting deployment"
|
||||
cleanup_markers_on_failure "eagle-${active}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 2: Start staging instance with new image
|
||||
log_info "Step 2: Starting eagle-${staging} with new image..."
|
||||
if [ "$staging" = "green" ]; then
|
||||
EAGLE_IMAGE_NEW="${new_image}" docker compose -f "${COMPOSE_FILE}" --profile blue-green up -d eagle-green jfr-sidecar-green
|
||||
else
|
||||
EAGLE_IMAGE="${new_image}" docker compose -f "${COMPOSE_FILE}" up -d eagle-blue jfr-sidecar
|
||||
fi
|
||||
|
||||
# Wait for staging to be healthy
|
||||
if ! wait_for_healthy "eagle-${staging}" 90; then
|
||||
log_error "Staging instance failed health check, aborting deployment"
|
||||
docker compose -f "${COMPOSE_FILE}" stop "eagle-${staging}"
|
||||
cleanup_markers_on_failure "eagle-${active}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 3: Run warmup/smoke test
|
||||
local staging_port
|
||||
if [ "$staging" = "green" ]; then
|
||||
staging_port=40034
|
||||
else
|
||||
staging_port=40032
|
||||
fi
|
||||
|
||||
log_info "Step 3: Running warmup against eagle-${staging}..."
|
||||
if [ -x "${WARMUP_SCRIPT}" ]; then
|
||||
if ! "${WARMUP_SCRIPT}" "localhost:${staging_port}"; then
|
||||
log_error "Warmup/smoke test failed, aborting deployment"
|
||||
docker compose -f "${COMPOSE_FILE}" stop "eagle-${staging}"
|
||||
cleanup_markers_on_failure "eagle-${active}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
log_warn "Warmup script not found at ${WARMUP_SCRIPT}, skipping warmup"
|
||||
log_warn "JIT will be cold on first requests"
|
||||
fi
|
||||
|
||||
# Step 4: Switch nginx to staging BEFORE stopping active
|
||||
# This achieves zero downtime - users immediately route to staging.
|
||||
# Any lazy-loads will wait for the flush marker (created in step 6).
|
||||
local nginx_switch_start
|
||||
nginx_switch_start=$(date +%s)
|
||||
log_info "[DEPLOY:${deploy_id}] Step 4: Switching nginx to eagle-${staging}..."
|
||||
|
||||
# Update nginx config (variable-based routing)
|
||||
if [ "$staging" = "green" ]; then
|
||||
sed -i.bak 's/eagle-blue:40032/eagle-green:40032/g' "${NGINX_CONF}"
|
||||
else
|
||||
sed -i.bak 's/eagle-green:40032/eagle-blue:40032/g' "${NGINX_CONF}"
|
||||
fi
|
||||
|
||||
# Recreate nginx to pick up new config
|
||||
# Use --no-deps to prevent cascading to auth (which has secrets not available here)
|
||||
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate --no-deps nginx
|
||||
|
||||
# Verify nginx picked up the correct config
|
||||
local nginx_backend
|
||||
nginx_backend=$(docker exec nginx grep -o 'eagle-[a-z]*:40032' /etc/nginx/nginx.conf | head -1 || echo "unknown")
|
||||
if [ "$nginx_backend" = "eagle-${staging}:40032" ]; then
|
||||
log_info "[DEPLOY:${deploy_id}] Verified: nginx routing to eagle-${staging}"
|
||||
else
|
||||
log_error "[DEPLOY:${deploy_id}] nginx config mismatch! Expected eagle-${staging}:40032, got ${nginx_backend}"
|
||||
exit 1
|
||||
fi
|
||||
log_info "[DEPLOY:${deploy_id}] Traffic switched to eagle-${staging} (lazy-loads will wait for flush marker)"
|
||||
local nginx_switch_end
|
||||
nginx_switch_end=$(date +%s)
|
||||
|
||||
# Step 5: Stop active instance (blocks until exit, ensuring flush completes)
|
||||
# Users may be lazy-loading on staging during this time - they'll wait for the marker.
|
||||
local flush_start
|
||||
flush_start=$(date +%s)
|
||||
log_info "[DEPLOY:${deploy_id}] Step 5: Stopping eagle-${active} (waiting for flush)..."
|
||||
docker compose -f "${COMPOSE_FILE}" stop "eagle-${active}"
|
||||
local flush_end
|
||||
flush_end=$(date +%s)
|
||||
local flush_duration=$((flush_end - flush_start))
|
||||
log_info "[DEPLOY:${deploy_id}] eagle-${active} stopped, flush completed in ${flush_duration}s"
|
||||
|
||||
# Step 6: Create flush marker - signals that disk state is fresh
|
||||
# Any waiting lazy-loads on staging will now proceed with fresh data.
|
||||
# The Eagle server automatically detects the flush marker update and invalidates any stale cached games.
|
||||
log_info "[DEPLOY:${deploy_id}] Step 6: Creating flush marker..."
|
||||
create_flush_marker "${deploy_id}" "eagle-${staging}"
|
||||
log_info "[DEPLOY:${deploy_id}] Flush marker created - server will auto-invalidate stale cache"
|
||||
|
||||
# Write active instance file for eagle-exec helper
|
||||
echo "eagle-${staging}" > "${ACTIVE_INSTANCE_FILE}"
|
||||
log_info "[DEPLOY:${deploy_id}] Active instance file updated: eagle-${staging}"
|
||||
|
||||
# Update .env for admin service
|
||||
local env_file="${APP_DIR}/.env"
|
||||
if [ "$staging" = "green" ]; then
|
||||
log_info "Updating .env for green instance..."
|
||||
sed -i.bak '/^EAGLE_ADDR=/d; /^JFR_SIDECAR_ADDR=/d' "${env_file}" 2>/dev/null || true
|
||||
echo "EAGLE_ADDR=eagle-green:40032" >> "${env_file}"
|
||||
echo "JFR_SIDECAR_ADDR=jfr-sidecar-green:8081" >> "${env_file}"
|
||||
else
|
||||
log_info "Updating .env for blue instance..."
|
||||
sed -i.bak '/^EAGLE_ADDR=/d; /^JFR_SIDECAR_ADDR=/d' "${env_file}" 2>/dev/null || true
|
||||
echo "EAGLE_ADDR=eagle-blue:40032" >> "${env_file}"
|
||||
echo "JFR_SIDECAR_ADDR=jfr-sidecar:8081" >> "${env_file}"
|
||||
fi
|
||||
|
||||
# Restart admin to pick up new .env and ensure latest image
|
||||
# Use --no-deps to prevent cascading to auth (which has secrets not available here)
|
||||
log_info "Restarting admin service..."
|
||||
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate --no-deps admin
|
||||
|
||||
# Verify admin container is running
|
||||
sleep 3
|
||||
if ! docker inspect admin-server --format '{{.State.Running}}' 2>/dev/null | grep -q "true"; then
|
||||
log_error "Admin container failed to start!"
|
||||
log_error "Container logs:"
|
||||
docker logs admin-server --tail 20 2>&1 || true
|
||||
log_error "Container inspect:"
|
||||
docker inspect admin-server 2>&1 | head -50 || true
|
||||
exit 1
|
||||
fi
|
||||
log_info "Admin service restarted successfully"
|
||||
|
||||
# Clean up old instance
|
||||
log_info "Cleaning up old eagle-${active}..."
|
||||
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${active}" 2>/dev/null || true
|
||||
|
||||
# Stop the old jfr-sidecar (it can't attach to removed container anyway)
|
||||
if [ "$active" = "green" ]; then
|
||||
docker compose -f "${COMPOSE_FILE}" stop "jfr-sidecar-green" 2>/dev/null || true
|
||||
docker compose -f "${COMPOSE_FILE}" rm -f "jfr-sidecar-green" 2>/dev/null || true
|
||||
else
|
||||
docker compose -f "${COMPOSE_FILE}" stop "jfr-sidecar" 2>/dev/null || true
|
||||
docker compose -f "${COMPOSE_FILE}" rm -f "jfr-sidecar" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
local deploy_end_time
|
||||
deploy_end_time=$(date +%s)
|
||||
local total_duration=$((deploy_end_time - deploy_start_time))
|
||||
local user_wait_window=$((flush_end - nginx_switch_end))
|
||||
|
||||
log_info ""
|
||||
log_info "========================================="
|
||||
log_info "[DEPLOY:${deploy_id}] Deployment complete!"
|
||||
log_info " Active instance: eagle-${staging}"
|
||||
log_info " Total duration: ${total_duration}s"
|
||||
log_info " Flush duration: ${flush_duration}s"
|
||||
log_info " Max user wait window: ${user_wait_window}s"
|
||||
log_info "========================================="
|
||||
}
|
||||
|
||||
# Check for required tools
|
||||
check_requirements() {
|
||||
if ! command -v docker &> /dev/null; then
|
||||
log_error "docker is required but not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v sed &> /dev/null; then
|
||||
log_error "sed is required but not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "${NGINX_CONF}" ]; then
|
||||
log_error "nginx config not found at ${NGINX_CONF}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "${COMPOSE_FILE}" ]; then
|
||||
log_error "docker-compose file not found at ${COMPOSE_FILE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure saves directory exists
|
||||
if [ ! -d "${SAVES_DIR}" ]; then
|
||||
log_info "Creating saves directory at ${SAVES_DIR}"
|
||||
mkdir -p "${SAVES_DIR}"
|
||||
fi
|
||||
|
||||
# Clean up any stale deployment-in-progress marker from a previous failed deploy
|
||||
if [ -f "${DEPLOYMENT_IN_PROGRESS}" ]; then
|
||||
log_warn "Found stale deployment-in-progress marker, removing it"
|
||||
remove_stale_deployment_marker
|
||||
fi
|
||||
}
|
||||
|
||||
# Run
|
||||
check_requirements
|
||||
main "$@"
|
||||
@@ -1,10 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1pv-WMXReccddPwev_YG9IXEGznuGHrYjNNEZ0Rb-ZhM/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/shardok/settings.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1p6I5nUMcoAPHIcqikVgbBCFVnqN9dpOEVClbS_wOI7M/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/eagle/settings.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1pv-WMXReccddPwev_YG9IXEGznuGHrYjNNEZ0Rb-ZhM/export?gid=0&format=tsv" > src/main/resources/net/eagle0/shardok/settings.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1p6I5nUMcoAPHIcqikVgbBCFVnqN9dpOEVClbS_wOI7M/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/settings.tsv
|
||||
|
||||
bazel run //src/main/go/net/eagle0/build/settings_generator:settings_generator -- \
|
||||
${PWD}/src/main/resources/net/eagle0/eagle/settings.tsv \
|
||||
${PWD}/src/main/scala/net/eagle0/eagle/library/settings/
|
||||
bazel run gazelle
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1DHEsiv4cY4gE6AX3sVH82K__mpBD1aznIYCQwQxA_F0/export?gid=0&format=tsv" | tr -d '\r' > /tmp/names.tsv
|
||||
bazel run //src/main/scala/net/eagle0/util:name_list_checker -- /tmp/names.tsv > /dev/null
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1DHEsiv4cY4gE6AX3sVH82K__mpBD1aznIYCQwQxA_F0/export?gid=0&format=tsv" > /tmp/names.tsv
|
||||
bazel run //src/main/scala/net/eagle0/util:name_list_checker -- /tmp/names.tsv > src/main/resources/net/eagle0/names.tsv
|
||||
bazel run //src/main/scala/net/eagle0/util:name_list_json_maker -- /tmp/names.tsv > src/main/resources/net/eagle0/names.json
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1NhvG73HKyVE36yGpkV2oJiSIXoNqQOYTr5ArLnucYL0/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/shardok/battalionTypes.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1pNWiyxIks2wJ1v7jRLFD24zrKHG2AfhC-nkWmQKQGN4/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/eagle/heroes.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1RUguq5eAQprsZwOOqiCc-1dg4Urc_6iJ6awZsFU4MeI/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/eagle/beasts.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1NhvG73HKyVE36yGpkV2oJiSIXoNqQOYTr5ArLnucYL0/export?gid=0&format=tsv" > src/main/resources/net/eagle0/shardok/battalionTypes.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1pNWiyxIks2wJ1v7jRLFD24zrKHG2AfhC-nkWmQKQGN4/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/heroes.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1RUguq5eAQprsZwOOqiCc-1dg4Urc_6iJ6awZsFU4MeI/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/beasts.tsv
|
||||
#curl -L "https://docs.google.com/spreadsheets/d/1Z-60cJ_N1IasvqpVb5awKEkIYznEeR2IZSdli47oW88/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/province_map.tsv
|
||||
|
||||
${PWD}/scripts/dlSettings.sh
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Helper to run docker exec against the active Eagle instance.
|
||||
# Reads the active instance from /opt/eagle0/.active-instance (set by deploy-blue-green.sh).
|
||||
#
|
||||
# Usage:
|
||||
# eagle-exec printenv GEMINI_API_KEY
|
||||
# eagle-exec jcmd 1 VM.flags
|
||||
# eagle-exec sh # Get a shell
|
||||
#
|
||||
# To create an alias, add to ~/.bashrc:
|
||||
# alias eagle-exec='/opt/eagle0/scripts/eagle-exec.sh'
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="${APP_DIR:-/opt/eagle0}"
|
||||
ACTIVE_FILE="${APP_DIR}/.active-instance"
|
||||
|
||||
# Read active instance from file, with fallback
|
||||
if [ -f "$ACTIVE_FILE" ]; then
|
||||
ACTIVE=$(cat "$ACTIVE_FILE")
|
||||
else
|
||||
# Fallback: check which container is actually running
|
||||
if docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null | grep -q true; then
|
||||
ACTIVE="eagle-blue"
|
||||
elif docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null | grep -q true; then
|
||||
ACTIVE="eagle-green"
|
||||
else
|
||||
ACTIVE=""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$ACTIVE" ]; then
|
||||
echo "Error: No active Eagle instance found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Active instance: $ACTIVE"
|
||||
echo "Usage: $0 <command> [args...]"
|
||||
echo "Example: $0 printenv GEMINI_API_KEY"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exec docker exec "$ACTIVE" "$@"
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Helper to tail logs from the active Eagle instance.
|
||||
# Reads the active instance from /opt/eagle0/.active-instance (set by deploy-blue-green.sh).
|
||||
#
|
||||
# Usage:
|
||||
# eagle-logs # Tail logs (follow mode)
|
||||
# eagle-logs -n 100 # Show last 100 lines and follow
|
||||
# eagle-logs --no-follow -n 50 # Show last 50 lines without following
|
||||
#
|
||||
# To create an alias, add to ~/.bashrc:
|
||||
# alias eagle-logs='/opt/eagle0/scripts/eagle-logs.sh'
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="${APP_DIR:-/opt/eagle0}"
|
||||
ACTIVE_FILE="${APP_DIR}/.active-instance"
|
||||
|
||||
# Read active instance from file, with fallback
|
||||
if [ -f "$ACTIVE_FILE" ]; then
|
||||
ACTIVE=$(cat "$ACTIVE_FILE")
|
||||
else
|
||||
# Fallback: check which container is actually running
|
||||
if docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null | grep -q true; then
|
||||
ACTIVE="eagle-blue"
|
||||
elif docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null | grep -q true; then
|
||||
ACTIVE="eagle-green"
|
||||
else
|
||||
ACTIVE=""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$ACTIVE" ]; then
|
||||
echo "Error: No active Eagle instance found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Default to follow mode if no args provided
|
||||
if [ $# -eq 0 ]; then
|
||||
exec docker logs -f "$ACTIVE"
|
||||
else
|
||||
exec docker logs "$@" "$ACTIVE"
|
||||
fi
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Restart the active Eagle instance.
|
||||
# Reads the active instance from /opt/eagle0/.active-instance (set by deploy-blue-green.sh).
|
||||
#
|
||||
# This is useful when you need Eagle to reload game state from persistence
|
||||
# (e.g., after a rewind) without doing a full blue-green deployment.
|
||||
#
|
||||
# Usage:
|
||||
# eagle-restart # Restart active instance, wait for healthy
|
||||
# eagle-restart --no-wait # Restart without waiting for health check
|
||||
#
|
||||
# To create an alias, add to ~/.bashrc:
|
||||
# alias eagle-restart='/opt/eagle0/scripts/eagle-restart.sh'
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APP_DIR="${APP_DIR:-/opt/eagle0}"
|
||||
ACTIVE_FILE="${APP_DIR}/.active-instance"
|
||||
HEALTH_TIMEOUT=120 # seconds
|
||||
|
||||
# Read active instance from file, with fallback
|
||||
if [ -f "$ACTIVE_FILE" ]; then
|
||||
ACTIVE=$(cat "$ACTIVE_FILE")
|
||||
else
|
||||
# Fallback: check which container is actually running
|
||||
if docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null | grep -q true; then
|
||||
ACTIVE="eagle-blue"
|
||||
elif docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null | grep -q true; then
|
||||
ACTIVE="eagle-green"
|
||||
else
|
||||
ACTIVE=""
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$ACTIVE" ]; then
|
||||
echo "Error: No active Eagle instance found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NO_WAIT=false
|
||||
if [ "${1:-}" = "--no-wait" ]; then
|
||||
NO_WAIT=true
|
||||
fi
|
||||
|
||||
echo "Restarting ${ACTIVE}..."
|
||||
docker restart "$ACTIVE"
|
||||
|
||||
if [ "$NO_WAIT" = true ]; then
|
||||
echo "Restart initiated. Not waiting for health check."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Waiting for ${ACTIVE} to become healthy (timeout: ${HEALTH_TIMEOUT}s)..."
|
||||
elapsed=0
|
||||
while [ $elapsed -lt $HEALTH_TIMEOUT ]; do
|
||||
health=$(docker inspect --format='{{.State.Health.Status}}' "$ACTIVE" 2>/dev/null || echo "unknown")
|
||||
if [ "$health" = "healthy" ]; then
|
||||
echo "${ACTIVE} is healthy after ${elapsed}s."
|
||||
exit 0
|
||||
fi
|
||||
sleep 2
|
||||
elapsed=$((elapsed + 2))
|
||||
# Print progress every 10 seconds
|
||||
if [ $((elapsed % 10)) -eq 0 ]; then
|
||||
echo " ...${elapsed}s (status: ${health})"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Warning: ${ACTIVE} did not become healthy within ${HEALTH_TIMEOUT}s (status: ${health})" >&2
|
||||
echo "Check logs with: eagle-logs" >&2
|
||||
exit 1
|
||||
@@ -1,602 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# generate_changelog.sh
|
||||
#
|
||||
# Generates a weekly changelog from merged PRs, uses Claude to create a synopsis,
|
||||
# and sends an HTML email via Fastmail JMAP API.
|
||||
#
|
||||
# Usage: ./scripts/generate_changelog.sh [--dry-run]
|
||||
#
|
||||
# Configuration files (in ~/.config/eagle0/):
|
||||
# fastmail_token - API token (required)
|
||||
# changelog_recipient - Email addresses, one per line (optional, defaults to sender)
|
||||
#
|
||||
# To set up:
|
||||
# mkdir -p ~/.config/eagle0
|
||||
# echo 'your-token' > ~/.config/eagle0/fastmail_token
|
||||
# chmod 600 ~/.config/eagle0/fastmail_token
|
||||
#
|
||||
# # Optional: configure recipients (one per line, # for comments)
|
||||
# cat > ~/.config/eagle0/changelog_recipient << EOF
|
||||
# alice@example.com
|
||||
# bob@example.com
|
||||
# EOF
|
||||
#
|
||||
# The script tracks its last run using a git tag 'changelog-last-run'.
|
||||
# On first run (no tag), it defaults to the previous Friday at 4pm.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Ensure homebrew binaries are in PATH
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
TAG_NAME="changelog-last-run"
|
||||
DRY_RUN=false
|
||||
FASTMAIL_API="https://api.fastmail.com/jmap/api/"
|
||||
CONFIG_DIR="$HOME/.config/eagle0"
|
||||
TOKEN_FILE="$CONFIG_DIR/fastmail_token"
|
||||
RECIPIENT_FILE="$CONFIG_DIR/changelog_recipient"
|
||||
|
||||
# Load API token from file or environment
|
||||
load_api_token() {
|
||||
# Environment variable takes precedence
|
||||
if [[ -n "${FASTMAIL_API_TOKEN:-}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Try loading from config file
|
||||
if [[ -f "$TOKEN_FILE" ]]; then
|
||||
FASTMAIL_API_TOKEN=$(cat "$TOKEN_FILE" | tr -d '[:space:]')
|
||||
if [[ -n "$FASTMAIL_API_TOKEN" ]]; then
|
||||
echo "Loaded API token from $TOKEN_FILE"
|
||||
export FASTMAIL_API_TOKEN
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Load recipient emails from config file (one per line)
|
||||
# Returns JSON array fragment like: {"email": "a@b.com"}, {"email": "c@d.com"}
|
||||
load_recipients_json() {
|
||||
local recipients=""
|
||||
if [[ -f "$RECIPIENT_FILE" ]]; then
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
# Skip empty lines and comments
|
||||
line=$(echo "$line" | tr -d '[:space:]')
|
||||
[[ -z "$line" || "$line" == \#* ]] && continue
|
||||
|
||||
if [[ -n "$recipients" ]]; then
|
||||
recipients="$recipients, "
|
||||
fi
|
||||
recipients="$recipients{\"email\": \"$line\"}"
|
||||
done < "$RECIPIENT_FILE"
|
||||
fi
|
||||
echo "$recipients"
|
||||
}
|
||||
|
||||
# Get human-readable list of recipients
|
||||
load_recipients_display() {
|
||||
if [[ -f "$RECIPIENT_FILE" ]]; then
|
||||
grep -v '^#' "$RECIPIENT_FILE" | grep -v '^[[:space:]]*$' | tr '\n' ', ' | sed 's/, $//'
|
||||
fi
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Usage: $0 [--dry-run]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Get the cutoff date - either from tag or previous Friday 4pm
|
||||
get_cutoff_date() {
|
||||
# Try to get the date from the tag
|
||||
if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then
|
||||
# Get the commit date of the tagged commit
|
||||
git log -1 --format="%aI" "$TAG_NAME"
|
||||
else
|
||||
# Calculate previous Friday at 4pm
|
||||
# Get current day of week (1=Monday, 7=Sunday)
|
||||
local dow=$(date +%u)
|
||||
local days_since_friday
|
||||
|
||||
if [[ $dow -ge 5 ]]; then
|
||||
# Friday (5), Saturday (6), or Sunday (7)
|
||||
days_since_friday=$((dow - 5))
|
||||
else
|
||||
# Monday (1) through Thursday (4)
|
||||
days_since_friday=$((dow + 2))
|
||||
fi
|
||||
|
||||
# Get previous Friday at 4pm in ISO format
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
date -v-"${days_since_friday}d" -v16H -v0M -v0S +"%Y-%m-%dT%H:%M:%S%z"
|
||||
else
|
||||
date -d "$days_since_friday days ago 16:00:00" --iso-8601=seconds
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Fetch merged PRs since the cutoff date
|
||||
fetch_merged_prs() {
|
||||
local since_date="$1"
|
||||
local output_file="$2"
|
||||
|
||||
echo "Fetching PRs merged since: $since_date"
|
||||
|
||||
# Use gh to search for merged PRs
|
||||
gh pr list \
|
||||
--state merged \
|
||||
--base main \
|
||||
--json number,title,body,mergedAt,author \
|
||||
--jq ".[] | select(.mergedAt >= \"$since_date\")" \
|
||||
> "$output_file.json"
|
||||
|
||||
# Format the output nicely
|
||||
echo "# Merged PRs since $since_date" > "$output_file"
|
||||
echo "" >> "$output_file"
|
||||
|
||||
# Process each PR
|
||||
jq -r '
|
||||
"## PR #\(.number): \(.title)\n" +
|
||||
"Author: \(.author.login)\n" +
|
||||
"Merged: \(.mergedAt)\n\n" +
|
||||
"### Description\n" +
|
||||
(.body // "(No description)") +
|
||||
"\n\n---\n"
|
||||
' "$output_file.json" >> "$output_file"
|
||||
|
||||
# Count PRs
|
||||
local pr_count=$(jq -s 'length' "$output_file.json")
|
||||
echo "Found $pr_count merged PRs"
|
||||
|
||||
rm -f "$output_file.json"
|
||||
|
||||
if [[ $pr_count -eq 0 ]]; then
|
||||
echo "No PRs found since $since_date"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Generate synopsis using Claude
|
||||
generate_synopsis() {
|
||||
local input_file="$1"
|
||||
local output_file="$2"
|
||||
|
||||
echo "Generating synopsis with Claude..."
|
||||
|
||||
# Create a prompt file to avoid shell escaping issues
|
||||
local prompt_file="/tmp/eagle0_prompt_$$.txt"
|
||||
# Get repo URL for PR links
|
||||
local repo_url=$(gh repo view --json url -q '.url')
|
||||
|
||||
cat > "$prompt_file" <<PROMPT_HEADER
|
||||
You are summarizing changes for a weekly engineering update email.
|
||||
|
||||
Read the following list of merged PRs and create a concise synopsis grouped by theme/feature/area of the codebase.
|
||||
|
||||
Structure:
|
||||
1. <h1> title (e.g., "Eagle0 Weekly Update")
|
||||
2. <h2>BLUF</h2> (Bottom Line Up Front) - A short prose paragraph (2-4 sentences) highlighting the 1-3 most important changes this week and what to look for when testing. This should be conversational and help readers quickly understand what matters most.
|
||||
3. Synopsis sections (<h2> headings with bullet point summaries)
|
||||
4. <hr> divider
|
||||
5. <h2>PR Details</h2> with the same groupings, but smaller (<h3> headings) and listing PR links
|
||||
- Format each PR as: <a href="${repo_url}/pull/NUMBER">#NUMBER</a>: Title
|
||||
|
||||
Guidelines for the SYNOPSIS sections:
|
||||
- Group related changes together under clear headings (use <h2> tags)
|
||||
- Use bullet points (<ul><li>) for individual changes
|
||||
- Highlight any significant new features, breaking changes, or important fixes
|
||||
- Keep the tone professional but accessible
|
||||
- Don't include PR numbers in the synopsis - focus on what changed and why it matters
|
||||
|
||||
IMPORTANT: Output valid HTML that can be used directly in an email body. Do NOT wrap in \`\`\`html code blocks - just output the raw HTML.
|
||||
|
||||
Here are the merged PRs:
|
||||
|
||||
PROMPT_HEADER
|
||||
|
||||
cat "$input_file" >> "$prompt_file"
|
||||
echo "" >> "$prompt_file"
|
||||
echo "Generate the synopsis now:" >> "$prompt_file"
|
||||
|
||||
# Use Claude CLI to generate the synopsis, wrapped in proper HTML with charset
|
||||
local raw_output="/tmp/eagle0_raw_$$.html"
|
||||
cat "$prompt_file" | claude --print > "$raw_output"
|
||||
|
||||
# Wrap in HTML document with UTF-8 charset
|
||||
cat > "$output_file" <<'HTML_HEAD'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
<body>
|
||||
HTML_HEAD
|
||||
cat "$raw_output" >> "$output_file"
|
||||
echo "</body></html>" >> "$output_file"
|
||||
|
||||
rm -f "$prompt_file" "$raw_output"
|
||||
echo "Synopsis generated at: $output_file"
|
||||
}
|
||||
|
||||
# Get Fastmail session info (account ID, identity ID, drafts mailbox ID)
|
||||
get_fastmail_session() {
|
||||
echo "Fetching Fastmail session info..." >&2
|
||||
|
||||
# Get session
|
||||
local session=$(curl -s \
|
||||
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
|
||||
"https://api.fastmail.com/jmap/session")
|
||||
|
||||
# Extract account ID (first account)
|
||||
FASTMAIL_ACCOUNT_ID=$(echo "$session" | jq -r '.primaryAccounts["urn:ietf:params:jmap:mail"]')
|
||||
|
||||
if [[ -z "$FASTMAIL_ACCOUNT_ID" || "$FASTMAIL_ACCOUNT_ID" == "null" ]]; then
|
||||
echo "Error: Could not get Fastmail account ID. Check your API token." >&2
|
||||
return 1
|
||||
fi
|
||||
echo "Account ID: $FASTMAIL_ACCOUNT_ID" >&2
|
||||
|
||||
# Get identity ID
|
||||
local identity_response=$(curl -s \
|
||||
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST \
|
||||
-d "{
|
||||
\"using\": [\"urn:ietf:params:jmap:core\", \"urn:ietf:params:jmap:mail\", \"urn:ietf:params:jmap:submission\"],
|
||||
\"methodCalls\": [
|
||||
[\"Identity/get\", {\"accountId\": \"$FASTMAIL_ACCOUNT_ID\"}, \"0\"]
|
||||
]
|
||||
}" \
|
||||
"$FASTMAIL_API")
|
||||
|
||||
FASTMAIL_IDENTITY_ID=$(echo "$identity_response" | jq -r '.methodResponses[0][1].list[0].id')
|
||||
FASTMAIL_FROM_EMAIL=$(echo "$identity_response" | jq -r '.methodResponses[0][1].list[0].email')
|
||||
|
||||
if [[ -z "$FASTMAIL_IDENTITY_ID" || "$FASTMAIL_IDENTITY_ID" == "null" ]]; then
|
||||
echo "Error: Could not get Fastmail identity ID." >&2
|
||||
return 1
|
||||
fi
|
||||
echo "Identity ID: $FASTMAIL_IDENTITY_ID (${FASTMAIL_FROM_EMAIL})" >&2
|
||||
|
||||
# Get drafts mailbox ID
|
||||
local mailbox_response=$(curl -s \
|
||||
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST \
|
||||
-d "{
|
||||
\"using\": [\"urn:ietf:params:jmap:core\", \"urn:ietf:params:jmap:mail\"],
|
||||
\"methodCalls\": [
|
||||
[\"Mailbox/query\", {\"accountId\": \"$FASTMAIL_ACCOUNT_ID\", \"filter\": {\"role\": \"drafts\"}}, \"0\"]
|
||||
]
|
||||
}" \
|
||||
"$FASTMAIL_API")
|
||||
|
||||
FASTMAIL_DRAFTS_ID=$(echo "$mailbox_response" | jq -r '.methodResponses[0][1].ids[0]')
|
||||
|
||||
if [[ -z "$FASTMAIL_DRAFTS_ID" || "$FASTMAIL_DRAFTS_ID" == "null" ]]; then
|
||||
echo "Error: Could not get Fastmail drafts mailbox ID." >&2
|
||||
return 1
|
||||
fi
|
||||
echo "Drafts mailbox ID: $FASTMAIL_DRAFTS_ID" >&2
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Send email via Fastmail JMAP API
|
||||
send_email_fastmail() {
|
||||
local synopsis_file="$1"
|
||||
local recipients_json="$2" # JSON array fragment: {"email": "a@b.com"}, {"email": "c@d.com"}
|
||||
|
||||
local subject="Eagle0 Weekly Changelog - $(date +%Y-%m-%d)"
|
||||
local html_body=$(cat "$synopsis_file" | jq -Rs .)
|
||||
|
||||
echo "Sending email via Fastmail JMAP API..."
|
||||
|
||||
# Create the email and send it in one request
|
||||
local response=$(curl -s \
|
||||
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST \
|
||||
-d "{
|
||||
\"using\": [
|
||||
\"urn:ietf:params:jmap:core\",
|
||||
\"urn:ietf:params:jmap:mail\",
|
||||
\"urn:ietf:params:jmap:submission\"
|
||||
],
|
||||
\"methodCalls\": [
|
||||
[\"Email/set\", {
|
||||
\"accountId\": \"$FASTMAIL_ACCOUNT_ID\",
|
||||
\"create\": {
|
||||
\"draft\": {
|
||||
\"from\": [{\"email\": \"$FASTMAIL_FROM_EMAIL\"}],
|
||||
\"to\": [$recipients_json],
|
||||
\"subject\": \"$subject\",
|
||||
\"mailboxIds\": {\"$FASTMAIL_DRAFTS_ID\": true},
|
||||
\"keywords\": {\"\$draft\": true},
|
||||
\"htmlBody\": [{\"partId\": \"body\", \"type\": \"text/html\"}],
|
||||
\"bodyValues\": {
|
||||
\"body\": {
|
||||
\"charset\": \"utf-8\",
|
||||
\"value\": $html_body
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, \"0\"],
|
||||
[\"EmailSubmission/set\", {
|
||||
\"accountId\": \"$FASTMAIL_ACCOUNT_ID\",
|
||||
\"onSuccessDestroyEmail\": [\"#sendIt\"],
|
||||
\"create\": {
|
||||
\"sendIt\": {
|
||||
\"emailId\": \"#draft\",
|
||||
\"identityId\": \"$FASTMAIL_IDENTITY_ID\"
|
||||
}
|
||||
}
|
||||
}, \"1\"]
|
||||
]
|
||||
}" \
|
||||
"$FASTMAIL_API")
|
||||
|
||||
# Check for errors
|
||||
local error=$(echo "$response" | jq -r '.methodResponses[0][1].notCreated.draft.description // empty')
|
||||
if [[ -n "$error" ]]; then
|
||||
echo "Error creating email: $error" >&2
|
||||
echo "Full response: $response" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local send_error=$(echo "$response" | jq -r '.methodResponses[1][1].notCreated.sendIt.description // empty')
|
||||
if [[ -n "$send_error" ]]; then
|
||||
echo "Error sending email: $send_error" >&2
|
||||
echo "Full response: $response" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Email sent successfully"
|
||||
}
|
||||
|
||||
# Generate player-friendly "What's New" summary
|
||||
# This creates a short summary suitable for in-game display
|
||||
generate_whats_new_summary() {
|
||||
local pr_file="$1"
|
||||
local output_file="/tmp/eagle0_whats_new_$$.txt"
|
||||
|
||||
echo "Generating player-friendly What's New summary..." >&2
|
||||
|
||||
# Create a prompt for player-facing summary
|
||||
local prompt_file="/tmp/eagle0_whats_new_prompt_$$.txt"
|
||||
|
||||
cat > "$prompt_file" <<'WHATS_NEW_PROMPT'
|
||||
Based on these merged PRs, write a SHORT player-friendly summary.
|
||||
Focus only on changes players will notice. Ignore internal/technical changes.
|
||||
|
||||
Output format (exactly this format, no markdown, no extra text):
|
||||
TITLE: (5-10 words describing the main change)
|
||||
DESCRIPTION: (1-2 sentences, what players can now do differently)
|
||||
CATEGORY: (one of: feature, improvement, fix, content)
|
||||
|
||||
If there are multiple notable player-visible changes, pick the single most important one.
|
||||
If there are no player-visible changes at all, output exactly: NONE
|
||||
|
||||
Examples of good output:
|
||||
TITLE: Bug Reporting
|
||||
DESCRIPTION: You can now report bugs directly from the Settings menu.
|
||||
CATEGORY: feature
|
||||
|
||||
TITLE: Faster Reconnection
|
||||
DESCRIPTION: The game now reconnects more smoothly after network interruptions.
|
||||
CATEGORY: improvement
|
||||
|
||||
Here are the merged PRs:
|
||||
|
||||
WHATS_NEW_PROMPT
|
||||
|
||||
cat "$pr_file" >> "$prompt_file"
|
||||
|
||||
# Use Claude CLI to generate the summary
|
||||
if ! command -v claude &> /dev/null; then
|
||||
echo "NONE"
|
||||
rm -f "$prompt_file"
|
||||
return
|
||||
fi
|
||||
|
||||
cat "$prompt_file" | claude --print > "$output_file" 2>/dev/null
|
||||
|
||||
rm -f "$prompt_file"
|
||||
|
||||
# Check if the output is NONE
|
||||
if grep -q "^NONE$" "$output_file"; then
|
||||
echo "NONE"
|
||||
rm -f "$output_file"
|
||||
return
|
||||
fi
|
||||
|
||||
# Return the output
|
||||
cat "$output_file"
|
||||
rm -f "$output_file"
|
||||
}
|
||||
|
||||
# URL encode a string for use in query parameters
|
||||
urlencode() {
|
||||
local string="$1"
|
||||
local strlen=${#string}
|
||||
local encoded=""
|
||||
local pos c o
|
||||
|
||||
for (( pos=0 ; pos<strlen ; pos++ )); do
|
||||
c=${string:$pos:1}
|
||||
case "$c" in
|
||||
[-_.~a-zA-Z0-9] ) o="${c}" ;;
|
||||
* ) printf -v o '%%%02x' "'$c"
|
||||
esac
|
||||
encoded+="${o}"
|
||||
done
|
||||
echo "${encoded}"
|
||||
}
|
||||
|
||||
# Open the admin console with pre-populated what's new content
|
||||
open_whats_new_preview() {
|
||||
local title="$1"
|
||||
local description="$2"
|
||||
local category="$3"
|
||||
|
||||
local encoded_title=$(urlencode "$title")
|
||||
local encoded_desc=$(urlencode "$description")
|
||||
local encoded_cat=$(urlencode "$category")
|
||||
|
||||
local preview_url="https://admin.eagle0.net/whats-new?preview=true&title=${encoded_title}&description=${encoded_desc}&category=${encoded_cat}"
|
||||
|
||||
echo ""
|
||||
echo "=== What's New Preview ==="
|
||||
echo "Title: $title"
|
||||
echo "Description: $description"
|
||||
echo "Category: $category"
|
||||
echo ""
|
||||
echo "Opening admin console preview..."
|
||||
|
||||
# Open the URL in the default browser
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
open "$preview_url"
|
||||
elif command -v xdg-open &> /dev/null; then
|
||||
xdg-open "$preview_url"
|
||||
else
|
||||
echo "Preview URL: $preview_url"
|
||||
fi
|
||||
}
|
||||
|
||||
# Update the tag to mark this run
|
||||
update_tag() {
|
||||
echo "Updating $TAG_NAME tag..."
|
||||
|
||||
# Delete existing tag if present
|
||||
git tag -d "$TAG_NAME" 2>/dev/null || true
|
||||
git push origin --delete "$TAG_NAME" 2>/dev/null || true
|
||||
|
||||
# Create new tag at HEAD
|
||||
git tag "$TAG_NAME"
|
||||
git push origin "$TAG_NAME"
|
||||
|
||||
echo "Tag updated to current HEAD"
|
||||
}
|
||||
|
||||
# Main
|
||||
main() {
|
||||
echo "=== Eagle0 Weekly Changelog Generator ==="
|
||||
echo ""
|
||||
|
||||
# Load API token (only required for actual send)
|
||||
if [[ "$DRY_RUN" != "true" ]]; then
|
||||
if ! load_api_token; then
|
||||
echo "Error: No Fastmail API token found."
|
||||
echo ""
|
||||
echo "To create a token:"
|
||||
echo "1. Go to Fastmail Settings -> Password & Security -> API tokens"
|
||||
echo "2. Create a new token with 'Email submission' scope"
|
||||
echo "3. Save it using one of these methods:"
|
||||
echo ""
|
||||
echo " Option A (recommended): Store in config file"
|
||||
echo " mkdir -p ~/.config/eagle0"
|
||||
echo " echo 'your-token' > ~/.config/eagle0/fastmail_token"
|
||||
echo " chmod 600 ~/.config/eagle0/fastmail_token"
|
||||
echo ""
|
||||
echo " Option B: Set environment variable"
|
||||
echo " export FASTMAIL_API_TOKEN='your-token'"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Get cutoff date
|
||||
local cutoff_date=$(get_cutoff_date)
|
||||
echo "Cutoff date: $cutoff_date"
|
||||
|
||||
# Create temp files
|
||||
local pr_file="/tmp/eagle0_prs_$(date +%s).md"
|
||||
local synopsis_file="/tmp/eagle0_synopsis_$(date +%s).html"
|
||||
|
||||
# Fetch PRs
|
||||
if ! fetch_merged_prs "$cutoff_date" "$pr_file"; then
|
||||
echo "No changes to report. Exiting."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "PR details saved to: $pr_file"
|
||||
|
||||
# Generate synopsis
|
||||
generate_synopsis "$pr_file" "$synopsis_file"
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo ""
|
||||
echo "=== DRY RUN - Synopsis content ==="
|
||||
cat "$synopsis_file"
|
||||
echo ""
|
||||
echo "=== DRY RUN - Skipping email send and tag update ==="
|
||||
else
|
||||
# Get Fastmail session info
|
||||
if ! get_fastmail_session; then
|
||||
echo "Failed to get Fastmail session info. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine recipients (from config file, or default to sender)
|
||||
local recipients_json=$(load_recipients_json)
|
||||
if [[ -z "$recipients_json" ]]; then
|
||||
recipients_json="{\"email\": \"$FASTMAIL_FROM_EMAIL\"}"
|
||||
echo "No recipients configured, sending to self ($FASTMAIL_FROM_EMAIL)"
|
||||
else
|
||||
local recipients_display=$(load_recipients_display)
|
||||
echo "Sending to: $recipients_display"
|
||||
fi
|
||||
|
||||
# Send email
|
||||
send_email_fastmail "$synopsis_file" "$recipients_json"
|
||||
|
||||
# Update tag for next run
|
||||
update_tag
|
||||
|
||||
# Generate player-friendly What's New summary and open admin preview
|
||||
echo ""
|
||||
echo "Generating What's New summary for in-game display..."
|
||||
whats_new_output=$(generate_whats_new_summary "$pr_file")
|
||||
|
||||
if [[ "$whats_new_output" != "NONE" ]]; then
|
||||
# Parse the output
|
||||
title=$(echo "$whats_new_output" | grep "^TITLE:" | sed 's/^TITLE:[[:space:]]*//')
|
||||
description=$(echo "$whats_new_output" | grep "^DESCRIPTION:" | sed 's/^DESCRIPTION:[[:space:]]*//')
|
||||
category=$(echo "$whats_new_output" | grep "^CATEGORY:" | sed 's/^CATEGORY:[[:space:]]*//')
|
||||
|
||||
if [[ -n "$title" && -n "$description" ]]; then
|
||||
open_whats_new_preview "$title" "$description" "$category"
|
||||
else
|
||||
echo "Could not parse What's New output, skipping preview"
|
||||
fi
|
||||
else
|
||||
echo "No player-visible changes detected, skipping What's New preview"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done!"
|
||||
echo "PR details: $pr_file"
|
||||
echo "Synopsis: $synopsis_file"
|
||||
}
|
||||
|
||||
main
|
||||
@@ -1,41 +0,0 @@
|
||||
// +build ignore
|
||||
|
||||
// Script to generate Ed25519 key pair for manifest signing.
|
||||
// Run with: go run scripts/generate_manifest_keys.go
|
||||
//
|
||||
// This will output:
|
||||
// - Private key (base64): Store as MANIFEST_SIGNING_KEY GitHub secret
|
||||
// - Public key (base64): Embed in EagleInstaller for verification
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Generate Ed25519 key pair
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to generate key pair: %v", err)
|
||||
}
|
||||
|
||||
// Encode keys as base64
|
||||
privateKeyB64 := base64.StdEncoding.EncodeToString(privateKey)
|
||||
publicKeyB64 := base64.StdEncoding.EncodeToString(publicKey)
|
||||
|
||||
fmt.Println("=== Ed25519 Key Pair for Manifest Signing ===")
|
||||
fmt.Println()
|
||||
fmt.Println("PRIVATE KEY (store as GitHub secret MANIFEST_SIGNING_KEY):")
|
||||
fmt.Println(privateKeyB64)
|
||||
fmt.Println()
|
||||
fmt.Println("PUBLIC KEY (embed in EagleInstaller.cs for verification):")
|
||||
fmt.Println(publicKeyB64)
|
||||
fmt.Println()
|
||||
fmt.Printf("Private key size: %d bytes\n", len(privateKey))
|
||||
fmt.Printf("Public key size: %d bytes\n", len(publicKey))
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Inject Sparkle framework into a macOS .app bundle for auto-updates
|
||||
# Usage: inject_sparkle.sh <app_path>
|
||||
#
|
||||
# Environment variables (required):
|
||||
# SPARKLE_EDDSA_PUBLIC_KEY - EdDSA public key for verifying updates
|
||||
#
|
||||
# Optional environment variables:
|
||||
# SPARKLE_FEED_URL - Appcast URL (default: https://assets.eagle0.net/mac/appcast.xml)
|
||||
# SPARKLE_VERSION - Sparkle version to use (default: 2.6.4)
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
APP_PATH="$1"
|
||||
SPARKLE_VERSION="${SPARKLE_VERSION:-2.6.4}"
|
||||
SPARKLE_FEED_URL="${SPARKLE_FEED_URL:-https://assets.eagle0.net/mac/appcast.xml}"
|
||||
SPARKLE_CACHE_DIR="/tmp/sparkle-cache"
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
echo "ERROR: App not found at $APP_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${SPARKLE_EDDSA_PUBLIC_KEY:-}" ]; then
|
||||
echo "ERROR: SPARKLE_EDDSA_PUBLIC_KEY environment variable not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Always use a fresh Sparkle download to avoid cache corruption issues
|
||||
SPARKLE_DIR="$SPARKLE_CACHE_DIR/Sparkle-$SPARKLE_VERSION"
|
||||
echo "=== Clearing Sparkle cache and downloading fresh copy ==="
|
||||
rm -rf "$SPARKLE_DIR"
|
||||
mkdir -p "$SPARKLE_DIR"
|
||||
|
||||
SPARKLE_URL="https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz"
|
||||
echo "Downloading from: $SPARKLE_URL"
|
||||
curl -L "$SPARKLE_URL" -o /tmp/sparkle.tar.xz
|
||||
tar -xJf /tmp/sparkle.tar.xz -C "$SPARKLE_DIR"
|
||||
rm /tmp/sparkle.tar.xz
|
||||
|
||||
# Show what was extracted
|
||||
echo "=== Extracted contents ==="
|
||||
ls -la "$SPARKLE_DIR/"
|
||||
|
||||
# The tarball extracts files directly, not into a subdirectory
|
||||
# Verify the framework has proper symlink structure
|
||||
echo "=== Verifying Sparkle.framework structure ==="
|
||||
ls -la "$SPARKLE_DIR/Sparkle.framework/"
|
||||
|
||||
if [ ! -L "$SPARKLE_DIR/Sparkle.framework/Sparkle" ]; then
|
||||
echo "ERROR: Sparkle.framework/Sparkle is not a symlink"
|
||||
file "$SPARKLE_DIR/Sparkle.framework/Sparkle"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -L "$SPARKLE_DIR/Sparkle.framework/Versions/Current" ]; then
|
||||
echo "ERROR: Sparkle.framework/Versions/Current is not a symlink"
|
||||
ls -la "$SPARKLE_DIR/Sparkle.framework/Versions/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Sparkle framework structure verified OK"
|
||||
|
||||
echo "=== Injecting Sparkle framework ==="
|
||||
FRAMEWORKS_DIR="$APP_PATH/Contents/Frameworks"
|
||||
mkdir -p "$FRAMEWORKS_DIR"
|
||||
|
||||
# Remove any existing Sparkle.framework in the app
|
||||
rm -rf "$FRAMEWORKS_DIR/Sparkle.framework"
|
||||
|
||||
# Copy Sparkle framework (use ditto to preserve symlinks and bundle structure)
|
||||
ditto "$SPARKLE_DIR/Sparkle.framework" "$FRAMEWORKS_DIR/Sparkle.framework"
|
||||
|
||||
# Verify the copied framework still has proper structure
|
||||
echo "=== Verifying copied Sparkle.framework structure ==="
|
||||
ls -la "$FRAMEWORKS_DIR/Sparkle.framework/"
|
||||
if [ ! -L "$FRAMEWORKS_DIR/Sparkle.framework/Sparkle" ]; then
|
||||
echo "ERROR: Copied framework lost symlink structure"
|
||||
exit 1
|
||||
fi
|
||||
echo "Copied framework structure OK"
|
||||
|
||||
echo "=== Updating Info.plist ==="
|
||||
PLIST_PATH="$APP_PATH/Contents/Info.plist"
|
||||
|
||||
# Add Sparkle configuration to Info.plist
|
||||
/usr/libexec/PlistBuddy -c "Delete :SUFeedURL" "$PLIST_PATH" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Add :SUFeedURL string '$SPARKLE_FEED_URL'" "$PLIST_PATH"
|
||||
|
||||
/usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$PLIST_PATH" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string '$SPARKLE_EDDSA_PUBLIC_KEY'" "$PLIST_PATH"
|
||||
|
||||
/usr/libexec/PlistBuddy -c "Delete :SUEnableAutomaticChecks" "$PLIST_PATH" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Add :SUEnableAutomaticChecks bool true" "$PLIST_PATH"
|
||||
|
||||
# Set bundle version from git for Sparkle version comparison
|
||||
# Use commit count for automatic incrementing versions (e.g., 1.0.9548)
|
||||
BUILD_NUMBER=$(git rev-list --count HEAD 2>/dev/null || echo "1")
|
||||
VERSION="1.0.${BUILD_NUMBER}"
|
||||
|
||||
echo "Setting version: $VERSION (build $BUILD_NUMBER)"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$PLIST_PATH" 2>/dev/null || \
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string '$VERSION'" "$PLIST_PATH"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST_PATH" 2>/dev/null || \
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleVersion string '$BUILD_NUMBER'" "$PLIST_PATH"
|
||||
|
||||
# Add URL scheme for invitation codes (eagle0://invite?code=XXXX)
|
||||
echo "=== Adding URL scheme for invitation codes ==="
|
||||
/usr/libexec/PlistBuddy -c "Delete :CFBundleURLTypes" "$PLIST_PATH" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes array" "$PLIST_PATH"
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0 dict" "$PLIST_PATH"
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLName string 'net.eagle0.eagle0'" "$PLIST_PATH"
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes array" "$PLIST_PATH"
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string 'eagle0'" "$PLIST_PATH"
|
||||
|
||||
echo "=== Sparkle injection complete ==="
|
||||
echo "App: $APP_PATH"
|
||||
echo "Feed URL: $SPARKLE_FEED_URL"
|
||||
echo "Version: $VERSION (build $BUILD_NUMBER)"
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Notarize a macOS .app bundle with Apple
|
||||
# Usage: notarize_mac_app.sh <app_path>
|
||||
#
|
||||
# Environment variables (required):
|
||||
# APPLE_ID - Apple Developer account email
|
||||
# APP_SPECIFIC_PASSWORD - App-specific password for notarytool
|
||||
# TEAM_ID - Apple Developer Team ID
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
APP_PATH="$1"
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
echo "ERROR: App not found at $APP_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${APPLE_ID:-}" ] || [ -z "${APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${TEAM_ID:-}" ]; then
|
||||
echo "ERROR: Required environment variables not set"
|
||||
echo " APPLE_ID: ${APPLE_ID:-<not set>}"
|
||||
echo " APP_SPECIFIC_PASSWORD: ${APP_SPECIFIC_PASSWORD:+<set>}"
|
||||
echo " TEAM_ID: ${TEAM_ID:-<not set>}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create ZIP for notarization submission
|
||||
ZIP_PATH="${APP_PATH%.app}.zip"
|
||||
echo "=== Creating ZIP for notarization: $ZIP_PATH ==="
|
||||
ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH"
|
||||
|
||||
echo "=== Submitting to Apple for notarization ==="
|
||||
SUBMIT_OUTPUT=$(xcrun notarytool submit "$ZIP_PATH" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$TEAM_ID" \
|
||||
--wait 2>&1) || true
|
||||
|
||||
echo "$SUBMIT_OUTPUT"
|
||||
|
||||
# Extract submission ID and status (look for " status:" to avoid matching "Current status:")
|
||||
SUBMISSION_ID=$(echo "$SUBMIT_OUTPUT" | grep "id:" | head -1 | awk '{print $2}')
|
||||
STATUS=$(echo "$SUBMIT_OUTPUT" | grep "^ status:" | awk '{print $2}')
|
||||
|
||||
echo "Submission ID: $SUBMISSION_ID"
|
||||
echo "Status: $STATUS"
|
||||
|
||||
# Clean up the zip (use -f to avoid failure if already deleted)
|
||||
rm -f "$ZIP_PATH"
|
||||
|
||||
if [ "$STATUS" != "Accepted" ]; then
|
||||
echo "=== Notarization failed! Fetching log for details ==="
|
||||
xcrun notarytool log "$SUBMISSION_ID" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$TEAM_ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Stapling notarization ticket to app ==="
|
||||
# Retry stapling - Apple's CloudKit can have a brief delay after notarization completes
|
||||
MAX_STAPLE_ATTEMPTS=5
|
||||
STAPLE_ATTEMPT=1
|
||||
while [ $STAPLE_ATTEMPT -le $MAX_STAPLE_ATTEMPTS ]; do
|
||||
echo "Stapling attempt $STAPLE_ATTEMPT/$MAX_STAPLE_ATTEMPTS..."
|
||||
if xcrun stapler staple "$APP_PATH"; then
|
||||
echo "Stapling successful"
|
||||
break
|
||||
fi
|
||||
if [ $STAPLE_ATTEMPT -eq $MAX_STAPLE_ATTEMPTS ]; then
|
||||
echo "ERROR: Stapling failed after $MAX_STAPLE_ATTEMPTS attempts"
|
||||
exit 1
|
||||
fi
|
||||
echo "Stapling failed, waiting 10 seconds before retry..."
|
||||
sleep 10
|
||||
STAPLE_ATTEMPT=$((STAPLE_ATTEMPT + 1))
|
||||
done
|
||||
|
||||
echo "=== Verifying notarization ==="
|
||||
xcrun stapler validate "$APP_PATH"
|
||||
spctl --assess --type exec -v "$APP_PATH"
|
||||
|
||||
echo "Notarization complete: $APP_PATH"
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Submit a macOS .app bundle to Apple for notarization (no waiting)
|
||||
# Usage: notarize_submit.sh <app_path>
|
||||
# Outputs: submission_id=<id> to stdout (for GitHub Actions)
|
||||
#
|
||||
# Environment variables (required):
|
||||
# APPLE_ID - Apple Developer account email
|
||||
# APP_SPECIFIC_PASSWORD - App-specific password for notarytool
|
||||
# TEAM_ID - Apple Developer Team ID
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
APP_PATH="$1"
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
echo "ERROR: App not found at $APP_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${APPLE_ID:-}" ] || [ -z "${APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${TEAM_ID:-}" ]; then
|
||||
echo "ERROR: Required environment variables not set" >&2
|
||||
echo " APPLE_ID: ${APPLE_ID:-<not set>}" >&2
|
||||
echo " APP_SPECIFIC_PASSWORD: ${APP_SPECIFIC_PASSWORD:+<set>}" >&2
|
||||
echo " TEAM_ID: ${TEAM_ID:-<not set>}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create ZIP for notarization submission
|
||||
ZIP_PATH="${APP_PATH%.app}.zip"
|
||||
echo "=== Creating ZIP for notarization: $ZIP_PATH ===" >&2
|
||||
ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH"
|
||||
|
||||
echo "=== Submitting to Apple for notarization ===" >&2
|
||||
SUBMIT_OUTPUT=$(xcrun notarytool submit "$ZIP_PATH" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$TEAM_ID" 2>&1)
|
||||
|
||||
echo "$SUBMIT_OUTPUT" >&2
|
||||
|
||||
# Extract submission ID
|
||||
SUBMISSION_ID=$(echo "$SUBMIT_OUTPUT" | grep "id:" | head -1 | awk '{print $2}')
|
||||
|
||||
if [ -z "$SUBMISSION_ID" ]; then
|
||||
echo "ERROR: Failed to get submission ID" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean up the zip
|
||||
rm "$ZIP_PATH"
|
||||
|
||||
echo "Submission ID: $SUBMISSION_ID" >&2
|
||||
|
||||
# Output for GitHub Actions
|
||||
echo "submission_id=$SUBMISSION_ID"
|
||||
@@ -1,88 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Wait for Apple notarization to complete and staple the ticket
|
||||
# Usage: notarize_wait.sh <submission_id> <app_path>
|
||||
#
|
||||
# Environment variables (required):
|
||||
# APPLE_ID - Apple Developer account email
|
||||
# APP_SPECIFIC_PASSWORD - App-specific password for notarytool
|
||||
# TEAM_ID - Apple Developer Team ID
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SUBMISSION_ID="$1"
|
||||
APP_PATH="$2"
|
||||
|
||||
if [ -z "$SUBMISSION_ID" ]; then
|
||||
echo "ERROR: submission_id is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
echo "ERROR: App not found at $APP_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${APPLE_ID:-}" ] || [ -z "${APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${TEAM_ID:-}" ]; then
|
||||
echo "ERROR: Required environment variables not set" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Waiting for notarization of submission $SUBMISSION_ID ==="
|
||||
WAIT_OUTPUT=$(xcrun notarytool wait "$SUBMISSION_ID" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$TEAM_ID" 2>&1) || true
|
||||
|
||||
echo "$WAIT_OUTPUT"
|
||||
|
||||
# Extract status (look for " status:" to avoid matching "Current status:")
|
||||
STATUS=$(echo "$WAIT_OUTPUT" | grep "^ status:" | awk '{print $2}')
|
||||
|
||||
echo "Status: $STATUS"
|
||||
|
||||
if [ "$STATUS" != "Accepted" ]; then
|
||||
echo "=== Notarization failed! Fetching log for details ==="
|
||||
xcrun notarytool log "$SUBMISSION_ID" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$TEAM_ID"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Verifying code signature before stapling ==="
|
||||
if ! codesign --verify --deep --strict "$APP_PATH" 2>&1; then
|
||||
echo "ERROR: Code signature verification failed - app may have been damaged during transfer"
|
||||
echo "Attempting to show signature details:"
|
||||
codesign -dvvv "$APP_PATH" 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Code signature verified successfully"
|
||||
|
||||
echo "=== Stapling notarization ticket to app ==="
|
||||
# Retry stapling - Apple's CloudKit can take several minutes to propagate the ticket
|
||||
MAX_STAPLE_ATTEMPTS=10
|
||||
STAPLE_WAIT_SECONDS=30
|
||||
STAPLE_ATTEMPT=1
|
||||
while [ $STAPLE_ATTEMPT -le $MAX_STAPLE_ATTEMPTS ]; do
|
||||
echo "Stapling attempt $STAPLE_ATTEMPT/$MAX_STAPLE_ATTEMPTS..."
|
||||
if STAPLE_OUTPUT=$(xcrun stapler staple "$APP_PATH" 2>&1); then
|
||||
echo "$STAPLE_OUTPUT"
|
||||
echo "Stapling successful"
|
||||
break
|
||||
fi
|
||||
echo "Stapler output: $STAPLE_OUTPUT"
|
||||
if [ $STAPLE_ATTEMPT -eq $MAX_STAPLE_ATTEMPTS ]; then
|
||||
echo "ERROR: Stapling failed after $MAX_STAPLE_ATTEMPTS attempts (total wait: $((MAX_STAPLE_ATTEMPTS * STAPLE_WAIT_SECONDS)) seconds)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Stapling failed, waiting $STAPLE_WAIT_SECONDS seconds before retry..."
|
||||
sleep $STAPLE_WAIT_SECONDS
|
||||
STAPLE_ATTEMPT=$((STAPLE_ATTEMPT + 1))
|
||||
done
|
||||
|
||||
echo "=== Verifying notarization ==="
|
||||
xcrun stapler validate "$APP_PATH"
|
||||
spctl --assess --type exec -v "$APP_PATH"
|
||||
|
||||
echo "Notarization complete: $APP_PATH"
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Pre-commit hook wrapper for gazelle that fails if files are modified.
|
||||
# This ensures BUILD files are in canonical format before committing.
|
||||
|
||||
set -e
|
||||
|
||||
# Run gazelle
|
||||
bazel run //:gazelle 2>/dev/null
|
||||
|
||||
# Check if any BUILD files were modified
|
||||
if ! git diff --quiet -- '*.bazel' '**/BUILD' 'WORKSPACE*'; then
|
||||
echo ""
|
||||
echo "ERROR: gazelle modified BUILD files. Please stage the changes and retry:"
|
||||
echo ""
|
||||
git diff --name-only -- '*.bazel' '**/BUILD' 'WORKSPACE*'
|
||||
echo ""
|
||||
echo "Run: git add -u && git commit"
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,149 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Setup script for Eagle0 production droplet
|
||||
# Run this on a fresh DigitalOcean droplet (Ubuntu 24.04)
|
||||
#
|
||||
# Usage: curl -sSL https://raw.githubusercontent.com/nolen777/eagle0/main/scripts/setup_droplet.sh | sudo bash
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DOMAIN="${DOMAIN:-eagle0.net}"
|
||||
DEPLOY_USER="${DEPLOY_USER:-deploy}"
|
||||
APP_DIR="/opt/eagle0"
|
||||
|
||||
echo "=== Eagle0 Production Server Setup ==="
|
||||
echo "Domain: ${DOMAIN}"
|
||||
echo "Deploy user: ${DEPLOY_USER}"
|
||||
echo ""
|
||||
|
||||
# Check if running as root
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This script must be run as root (use sudo)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Updating system ==="
|
||||
apt-get update
|
||||
apt-get upgrade -y
|
||||
|
||||
echo "=== Installing Docker ==="
|
||||
if ! command -v docker &> /dev/null; then
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
systemctl enable docker
|
||||
systemctl start docker
|
||||
else
|
||||
echo "Docker already installed"
|
||||
fi
|
||||
|
||||
echo "=== Installing Docker Compose plugin ==="
|
||||
apt-get install -y docker-compose-plugin
|
||||
|
||||
echo "=== Installing additional utilities ==="
|
||||
apt-get install -y \
|
||||
curl \
|
||||
wget \
|
||||
git \
|
||||
netcat-openbsd \
|
||||
jq \
|
||||
htop \
|
||||
unattended-upgrades
|
||||
|
||||
echo "=== Configuring automatic security updates ==="
|
||||
cat > /etc/apt/apt.conf.d/20auto-upgrades << 'EOF'
|
||||
APT::Periodic::Update-Package-Lists "1";
|
||||
APT::Periodic::Unattended-Upgrade "1";
|
||||
APT::Periodic::AutocleanInterval "7";
|
||||
EOF
|
||||
|
||||
echo "=== Creating deploy user ==="
|
||||
if ! id "${DEPLOY_USER}" &>/dev/null; then
|
||||
useradd -m -s /bin/bash -G docker "${DEPLOY_USER}"
|
||||
mkdir -p "/home/${DEPLOY_USER}/.ssh"
|
||||
chmod 700 "/home/${DEPLOY_USER}/.ssh"
|
||||
chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "/home/${DEPLOY_USER}/.ssh"
|
||||
echo ""
|
||||
echo "*** IMPORTANT: Add your SSH public key to /home/${DEPLOY_USER}/.ssh/authorized_keys ***"
|
||||
echo ""
|
||||
else
|
||||
echo "User ${DEPLOY_USER} already exists"
|
||||
# Ensure user is in docker group
|
||||
usermod -aG docker "${DEPLOY_USER}"
|
||||
fi
|
||||
|
||||
echo "=== Creating application directory ==="
|
||||
mkdir -p "${APP_DIR}"/{nginx,certbot/conf,certbot/www,saves}
|
||||
chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "${APP_DIR}"
|
||||
|
||||
echo "=== Configuring Docker registry authentication ==="
|
||||
echo ""
|
||||
echo "*** IMPORTANT: Run the following command to authenticate with DigitalOcean Container Registry: ***"
|
||||
echo " docker login registry.digitalocean.com"
|
||||
echo ""
|
||||
|
||||
echo "=== Creating systemd service ==="
|
||||
cat > /etc/systemd/system/eagle0.service << EOF
|
||||
[Unit]
|
||||
Description=Eagle0 Game Servers
|
||||
Requires=docker.service
|
||||
After=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=${APP_DIR}
|
||||
ExecStart=/usr/bin/docker compose -f docker-compose.prod.yml up -d
|
||||
ExecStop=/usr/bin/docker compose -f docker-compose.prod.yml down
|
||||
User=${DEPLOY_USER}
|
||||
Group=${DEPLOY_USER}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable eagle0
|
||||
|
||||
echo "=== Configuring firewall (UFW) ==="
|
||||
if ! command -v ufw &> /dev/null; then
|
||||
apt-get install -y ufw
|
||||
fi
|
||||
|
||||
ufw default deny incoming
|
||||
ufw default allow outgoing
|
||||
ufw allow ssh
|
||||
ufw allow 80/tcp
|
||||
ufw allow 443/tcp
|
||||
ufw --force enable
|
||||
|
||||
echo "=== Setting up log rotation ==="
|
||||
cat > /etc/logrotate.d/eagle0 << EOF
|
||||
/var/log/eagle0/*.log {
|
||||
daily
|
||||
missingok
|
||||
rotate 14
|
||||
compress
|
||||
delaycompress
|
||||
notifempty
|
||||
create 0640 ${DEPLOY_USER} ${DEPLOY_USER}
|
||||
sharedscripts
|
||||
}
|
||||
EOF
|
||||
|
||||
mkdir -p /var/log/eagle0
|
||||
chown "${DEPLOY_USER}:${DEPLOY_USER}" /var/log/eagle0
|
||||
|
||||
echo ""
|
||||
echo "=== Setup Complete ==="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Add SSH public key to /home/${DEPLOY_USER}/.ssh/authorized_keys"
|
||||
echo "2. Copy docker-compose.prod.yml to ${APP_DIR}/"
|
||||
echo "3. Copy nginx/nginx.conf to ${APP_DIR}/nginx/"
|
||||
echo "4. Create .env file in ${APP_DIR}/ with OPENAI_API_KEY"
|
||||
echo "5. Run: docker login registry.digitalocean.com"
|
||||
echo "6. Get SSL certificate: (see init_ssl.sh)"
|
||||
echo "7. Start services: systemctl start eagle0"
|
||||
echo ""
|
||||
echo "Server IP: $(curl -s ifconfig.me)"
|
||||
echo ""
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Keeps Bazel mactools builds in sync with the installed Xcode version.
|
||||
#
|
||||
# Only needed on runners that do mactools builds (Mac/Sparkle). Non-Mac builds
|
||||
# don't run this script; their apple_cc_autoconf stays cached because none of
|
||||
# its environ vars change, so they're unaffected by Xcode version changes.
|
||||
#
|
||||
# 1. Writes .bazelrc.xcode with mactools-scoped flags:
|
||||
# - action_env for remote cache key invalidation
|
||||
# - DEVELOPER_DIR pointing to the active Xcode
|
||||
#
|
||||
# 2. Runs `bazel clean --expunge` when the version actually changes, because
|
||||
# local_config_apple_cc (a cached repository rule) bakes in the old version
|
||||
# and can only be refreshed by clearing the output base.
|
||||
#
|
||||
# .bazelrc imports the generated file via: try-import %workspace%/.bazelrc.xcode
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
XCODE_BUILD_VERSION=$(xcodebuild -version 2>/dev/null | awk '/Build version/ {print $3}')
|
||||
|
||||
if [ -z "$XCODE_BUILD_VERSION" ]; then
|
||||
echo "Warning: Could not detect Xcode build version, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
DEVELOPER_DIR=$(xcode-select -p 2>/dev/null)
|
||||
|
||||
BAZELRC_XCODE=".bazelrc.xcode"
|
||||
EXPECTED_LINE="common:mactools --action_env=XCODE_BUILD_VERSION=${XCODE_BUILD_VERSION}"
|
||||
|
||||
# Check if the file already has the right version (first line is the version marker)
|
||||
if [ -f "$BAZELRC_XCODE" ]; then
|
||||
CURRENT_FIRST_LINE=$(head -1 "$BAZELRC_XCODE")
|
||||
if [ "$CURRENT_FIRST_LINE" = "$EXPECTED_LINE" ]; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Xcode version changed (or first run) — expunge local cache to clear
|
||||
# stale local_config_apple_cc, then write the new config
|
||||
OLD_VERSION="unknown"
|
||||
if [ -f "$BAZELRC_XCODE" ]; then
|
||||
OLD_VERSION=$(sed -n 's/.*XCODE_BUILD_VERSION=//p' "$BAZELRC_XCODE" | head -1)
|
||||
fi
|
||||
echo "Xcode build version changed: ${OLD_VERSION} -> ${XCODE_BUILD_VERSION}"
|
||||
echo "Running bazel clean --expunge to clear stale toolchain config..."
|
||||
bazel clean --expunge 2>/dev/null || true
|
||||
|
||||
cat > "$BAZELRC_XCODE" << EOF
|
||||
${EXPECTED_LINE}
|
||||
common:mactools --repo_env=DEVELOPER_DIR=${DEVELOPER_DIR}
|
||||
EOF
|
||||
echo "Updated ${BAZELRC_XCODE} — next mactools build will rebuild with Xcode ${XCODE_BUILD_VERSION}"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user