Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 4.5 e82f28e678 Make CommandChoiceHelpers protoless
Convert all command choice helper files to use Scala types instead of
proto types. This eliminates proto dependencies from the command
selection logic in the library.

Key changes:
- Update AvailableCommandSelector, CommandChoiceHelpers, and all
  command selector files to use Scala AvailableCommand/SelectedCommand
- Convert CommandSelection to ScalaCommandSelection throughout
- Update action files (EndHandleRiotsPhaseAction,
  PerformVassalCommandsPhaseAction, PerformVassalDefenseDecisionsAction)
  to work with Scala commands directly
- Update all quest command choosers to use Scala types
- Fix ArmedBattalion Scala definition (battalionTypeId -> newArmament)
- Add exports to combat_unit_selector BUILD.bazel

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 22:37:27 -08:00
adminandClaude Opus 4.5 eb37b9a734 Fix ArmedBattalion field name: battalionTypeId -> newArmament
The ArmedBattalion.battalionTypeId field was misnamed - it represents
the armament level to raise troops to, not a battalion type ID.
Renamed to newArmament to match the proto field name and the
semantic meaning.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 22:36:39 -08:00
10521 changed files with 966573 additions and 975414 deletions
+7 -32
View File
@@ -1,14 +1,10 @@
# bazel-1.0.0.bazelrc
bazel-1.0.0.bazelrc
# for now: filter out annoying TASTY warnings
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
@@ -29,39 +25,18 @@ common --host_cxxopt="--std=c++23"
common --javacopt="-Xlint:-options"
# std::filesystem and other modern C++ deps require macOS 10.15+.
common:macos --macos_minimum_os=10.15
common:macos --host_macos_minimum_os=10.15
# 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
# 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 Apple repo rules use full Xcode instead of Command Line
# Tools. The sync script writes .bazelrc.xcode with an Xcode build-version
# marker so in-place Xcode updates refresh Bazel's generated Apple toolchains.
# Fix Xcode version caching issue - avoids need for `bazel clean --expunge` after Xcode updates
common:macos --repo_env=DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
# Local machine or CI overrides. This lets Bazel-only runners use Command Line
# Tools without installing full Xcode. This must stay before .bazelrc.xcode so
# generated Xcode settings win when both files exist.
try-import %workspace%/.bazelrc.local
# Xcode config for macOS builds. 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
common --java_language_version=17
common --java_runtime_version=remotejdk_17
common --tool_java_language_version=17
common --tool_java_runtime_version=remotejdk_17
# Workspace status for build stamping (git commit, timestamp)
# 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 --stamp
+1 -1
View File
@@ -1 +1 @@
8.5.1
7.6.1
-31
View File
@@ -5,39 +5,8 @@
*.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,76 +0,0 @@
name: Artifact Storage Check
on:
schedule:
# Run every 6 hours
- cron: '0 */6 * * *'
workflow_dispatch:
permissions:
contents: read
actions: write
jobs:
cleanup-expired:
runs-on: ubuntu-latest
steps:
- name: Delete expired artifacts and artifacts older than 3 days
env:
GH_TOKEN: ${{ github.token }}
run: |
echo "Fetching all artifacts..."
gh api "repos/${{ github.repository }}/actions/artifacts" \
--paginate -q '.artifacts[] | "\(.id)\t\(.created_at)\t\(.expired)\t\(.name)"' > /tmp/all_artifacts.txt
total=$(wc -l < /tmp/all_artifacts.txt)
echo "Found $total total artifacts"
cutoff=$(date -u -d '3 days ago' '+%Y-%m-%dT%H:%M:%SZ')
echo "Deleting expired artifacts and artifacts created before $cutoff"
deleted=0
while IFS=$'\t' read -r id created_at expired name; do
if [[ "$expired" == "true" || "$created_at" < "$cutoff" ]]; then
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$id" 2>/dev/null && deleted=$((deleted + 1))
if [ $((deleted % 50)) -eq 0 ]; then
echo "Deleted $deleted artifacts so far..."
fi
fi
done < /tmp/all_artifacts.txt
echo "Cleanup complete. Deleted $deleted artifacts out of $total total."
rm -f /tmp/all_artifacts.txt
check-storage:
needs: cleanup-expired
runs-on: ubuntu-latest
steps:
- name: Check artifact storage size
env:
GH_TOKEN: ${{ github.token }}
run: |
# Calculate active artifact storage. The artifacts API can list expired
# artifacts until they are explicitly deleted, so do not count them.
total_bytes=$(gh api "repos/${{ github.repository }}/actions/artifacts" \
--paginate -q '.artifacts[] | select(.expired == false) | .size_in_bytes' | awk '{sum+=$1} END {print sum}')
total_mb=$(( ${total_bytes:-0} / 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[] | select(.expired == false) | "\(.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."
+46 -88
View File
@@ -9,10 +9,7 @@ on:
- 'src/main/protobuf/net/eagle0/eagle/api/auth.proto'
- 'src/main/protobuf/net/eagle0/eagle/api/admin/**'
- 'src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto'
- 'src/main/resources/net/eagle0/attributions.json'
- 'ci/BUILD.bazel'
- 'ci/github_actions/ensure_bazel_installed.sh'
- '.bazelrc'
- '.github/workflows/auth_build.yml'
workflow_dispatch:
inputs:
@@ -25,33 +22,24 @@ on:
permissions:
contents: read
concurrency:
group: auth-build-deploy
cancel-in-progress: false
jobs:
build-auth:
runs-on: [self-hosted, bazel, halfdan]
runs-on: [self-hosted, bazel]
outputs:
image_tag: ${{ steps.push-auth.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
persist-credentials: false
lfs: false
- name: Ensure Bazel installed
run: ./ci/github_actions/ensure_bazel_installed.sh
- 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
bazel build //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)
@@ -88,14 +76,8 @@ jobs:
# Build the push target to get crane in runfiles
bazel build //ci:auth_server_push
# Find crane binary in runfiles (path varies by Bazel version)
RUNFILES="bazel-bin/ci/push_auth_server_push.sh.runfiles"
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
if [ -z "$CRANE" ] || [ ! -x "$CRANE" ]; then
echo "ERROR: crane not found in runfiles"
find "$RUNFILES" -name crane 2>/dev/null || echo "No crane found at all"
exit 1
fi
# Use crane directly for push
CRANE="bazel-bin/ci/push_auth_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
echo "Using crane: $CRANE"
# Push with SHA tag
@@ -122,100 +104,76 @@ jobs:
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@v6
uses: actions/checkout@v4
- name: Copy update-env script to server
uses: appleboy/scp-action@v0.1.7
with:
persist-credentials: false
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
source: "deploy/update-env.sh,deploy/env.template"
target: /opt/eagle0/
strip_components: 1
- name: Deploy auth service to production
uses: appleboy/ssh-action@v1.2.5
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
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,JWT_PRIVATE_KEY,FASTMAIL_API_TOKEN,FASTMAIL_FROM_EMAIL,FASTMAIL_FROM_NAME
script: |
set -e
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}"
# Update env vars using shared script (preserves vars set by other workflows)
chmod +x update-env.sh
./update-env.sh \
"AUTH_IMAGE=${AUTH_IMAGE}" \
"DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}" \
"DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}" \
"GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}" \
"GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}" \
"JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}" \
"FASTMAIL_API_TOKEN=${FASTMAIL_API_TOKEN}" \
"FASTMAIL_FROM_EMAIL=${FASTMAIL_FROM_EMAIL}" \
"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; }
# Use crane to pull image
echo "Installing crane..."
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
chmod +x crane
# Tag as :latest locally so any fallback uses correct image
docker tag "${AUTH_IMAGE}" registry.digitalocean.com/eagle0/auth-server:latest
echo "Pulling Auth image with crane..."
./crane pull "${AUTH_IMAGE}" auth.tar || { echo "ERROR: Failed to pull auth image"; exit 1; }
echo "Loading Auth image into Docker..."
docker load -i auth.tar
rm auth.tar
rm ./crane
# 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
# Only recreate the auth container (not eagle, shardok, etc.)
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
# Verify container is using correct image
echo "=== Verifying auth container image ==="
docker compose -f docker-compose.prod.yml images auth
# Cleanup old images
docker image prune -f
-41
View File
@@ -1,41 +0,0 @@
name: Bazel Cache Cleanup
on:
# Disabled: bazel clean fails when another runner shares the output_base
# on /Volumes/remote_cache (unlinkat "Directory not empty" race).
# See https://github.com/nolen777/eagle0/issues/TBD for details.
# schedule:
# - cron: '0 0 * * 0'
workflow_dispatch: # Allow manual trigger
jobs:
cleanup:
runs-on: [self-hosted, bazel, halfdan]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
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 .
+9 -79
View File
@@ -8,8 +8,6 @@ on:
- 'WORKSPACE'
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.bazelrc'
- 'ci/github_actions/ensure_bazel_installed.sh'
- '.github/workflows/bazel_test.yml'
- '!src/main/csharp/**'
- '!src/test/csharp/**'
@@ -19,94 +17,25 @@ on:
- 'WORKSPACE'
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.bazelrc'
- 'ci/github_actions/ensure_bazel_installed.sh'
- '.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: Ensure Git LFS available for checkout
run: |
COMMON_PATHS=(/opt/homebrew/bin /usr/local/bin)
for path in "${COMMON_PATHS[@]}"; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
export PATH="$path:$PATH"
fi
done
if command -v git-lfs >/dev/null 2>&1; then
git-lfs version
exit 0
fi
brew install git-lfs
git-lfs version
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: false
lfs: false
- name: Check BUILD.bazel dependencies
run: ./scripts/check_build_deps.sh --strict
- name: Set up Node.js
uses: actions/setup-node@v6
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]
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: Ensure Git LFS available for checkout
run: |
COMMON_PATHS=(/opt/homebrew/bin /usr/local/bin)
for path in "${COMMON_PATHS[@]}"; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
export PATH="$path:$PATH"
fi
done
if command -v git-lfs >/dev/null 2>&1; then
git-lfs version
exit 0
fi
brew install git-lfs
git-lfs version
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
persist-credentials: false
lfs: false
- name: Ensure Bazel installed
run: ./ci/github_actions/ensure_bazel_installed.sh
- name: Run tests
id: test
continue-on-error: true
run: bazel test --build_event_json_file=test.json //src/test/... //src/main/go/...
- name: Collect failed test logs
if: always()
@@ -142,16 +71,17 @@ jobs:
ls -lh failed_test_logs/ 2>/dev/null || echo "No logs collected"
- name: Archive test results
if: always()
uses: actions/upload-artifact@v7
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@v7
uses: actions/upload-artifact@v4
with:
name: failed-test-logs
path: failed_test_logs/
if-no-files-found: ignore
retention-days: 3
- name: Fail if tests failed
if: steps.test.outcome == 'failure'
run: exit 1
-31
View File
@@ -1,31 +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, halfdan]
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
lfs: false
clean: false
- name: Ensure Bazel installed
run: ./ci/github_actions/ensure_bazel_installed.sh
- 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
+6 -12
View File
@@ -27,19 +27,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Build sysroot
run: ./tools/sysroot/build_sysroot.sh
- name: Upload sysroot artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: ubuntu-noble-sysroot-amd64
path: tools/sysroot/output/
retention-days: 1
- name: Install AWS CLI
run: |
@@ -83,27 +80,24 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: false
uses: actions/checkout@v4
- name: Set up QEMU for ARM64 emulation
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
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@v7
uses: actions/upload-artifact@v4
with:
name: ubuntu-noble-sysroot-arm64
path: tools/sysroot/output/
retention-days: 1
- name: Install AWS CLI
run: |
+113 -362
View File
@@ -5,22 +5,12 @@ on:
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'
- 'ci/github_actions/ensure_bazel_installed.sh'
- 'MODULE.bazel'
- '.bazelrc'
- 'docker-compose.prod.yml'
- 'nginx/**'
- '.github/workflows/docker_build.yml'
@@ -32,99 +22,49 @@ on:
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.
# Only allow one deployment at a time to prevent race conditions
concurrency:
group: docker-build-deploy
cancel-in-progress: false
cancel-in-progress: false # Don't cancel running deployments, queue new ones
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, halfdan]
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: Ensure Git LFS available for checkout
if: steps.check-latest.outputs.skip != 'true'
run: |
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH="${COMMON_PATHS}:${PATH}"
for path in /opt/homebrew/bin /usr/local/bin; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
fi
done
if ! command -v git-lfs >/dev/null 2>&1; then
brew install git-lfs
fi
git-lfs --version
- name: Checkout repository
if: steps.check-latest.outputs.skip != 'true'
uses: actions/checkout@v6
env:
GIT_LFS_SKIP_SMUDGE: 1
uses: actions/checkout@v4
with:
persist-credentials: false
lfs: false
- name: Ensure Bazel installed
if: steps.check-latest.outputs.skip != 'true'
run: ./ci/github_actions/ensure_bazel_installed.sh
- name: Fetch LFS files needed for admin server
if: steps.check-latest.outputs.skip != 'true'
env:
GITHUB_TOKEN: ${{ github.token }}
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
//src/main/go/net/eagle0/warmup:warmup_linux_amd64
# Extract warmup binary from tar for deployment
# Copy warmup binary to scripts/ for deployment
mkdir -p scripts/bin
tar -xf bazel-bin/ci/warmup_tar.tar -C scripts/bin --strip-components=1
cp bazel-bin/src/main/go/net/eagle0/warmup/warmup_linux_amd64_/warmup_linux_amd64 scripts/bin/warmup
# Save all image paths before any other bazel command changes bazel-bin symlink
EAGLE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
@@ -140,16 +80,8 @@ jobs:
echo "Admin: $ADMIN_PATH"
echo "JFR Sidecar: $JFR_PATH"
- name: Upload warmup binary
if: steps.check-latest.outputs.skip != 'true'
uses: actions/upload-artifact@v7
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'))
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
@@ -169,65 +101,50 @@ jobs:
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"
# Get crane from push target runfiles
bazel build //ci:eagle_server_push
CRANE="bazel-bin/ci/push_eagle_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
if [ ! -e "$CRANE" ]; then
# Fallback: find any Darwin crane
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
fi
# 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"
if [ -z "$CRANE" ] || [ ! -e "$CRANE" ]; then
echo "ERROR: crane not found"
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 "Using crane: $CRANE"
# Push Eagle image
EAGLE_IMAGE="${{ steps.build-all.outputs.eagle_path }}"
EAGLE_TAG="registry.digitalocean.com/eagle0/eagle-server:${GIT_SHA}"
echo "Pushing Eagle: $EAGLE_TAG"
$CRANE push "$EAGLE_IMAGE" "$EAGLE_TAG"
$CRANE copy "$EAGLE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest"
echo "eagle_image_tag=$EAGLE_TAG" >> $GITHUB_OUTPUT
# Push Admin image
ADMIN_IMAGE="${{ steps.build-all.outputs.admin_path }}"
ADMIN_TAG="registry.digitalocean.com/eagle0/admin-server:${GIT_SHA}"
echo "Pushing Admin: $ADMIN_TAG"
$CRANE push "$ADMIN_IMAGE" "$ADMIN_TAG"
$CRANE copy "$ADMIN_TAG" "registry.digitalocean.com/eagle0/admin-server:latest"
echo "admin_image_tag=$ADMIN_TAG" >> $GITHUB_OUTPUT
# Push JFR Sidecar image
JFR_IMAGE="${{ steps.build-all.outputs.jfr_path }}"
JFR_TAG="registry.digitalocean.com/eagle0/jfr-sidecar:${GIT_SHA}"
echo "Pushing JFR Sidecar: $JFR_TAG"
$CRANE push "$JFR_IMAGE" "$JFR_TAG"
$CRANE copy "$JFR_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest"
echo "jfr_sidecar_image_tag=$JFR_TAG" >> $GITHUB_OUTPUT
echo "=== All images pushed successfully ==="
deploy:
runs-on: [self-hosted, bazel, halfdan]
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
@@ -236,66 +153,26 @@ jobs:
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 }}
EAGLE_HISTORY_BACKEND: ${{ secrets.EAGLE_HISTORY_BACKEND }}
EAGLE_POSTGRES_HOST: ${{ secrets.EAGLE_POSTGRES_HOST }}
EAGLE_POSTGRES_PORT: ${{ secrets.EAGLE_POSTGRES_PORT }}
EAGLE_POSTGRES_DATABASE: ${{ secrets.EAGLE_POSTGRES_DATABASE }}
EAGLE_POSTGRES_USER: ${{ secrets.EAGLE_POSTGRES_USER }}
EAGLE_POSTGRES_PASSWORD: ${{ secrets.EAGLE_POSTGRES_PASSWORD }}
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: Ensure Git LFS available for checkout
run: |
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH="${COMMON_PATHS}:${PATH}"
for path in /opt/homebrew/bin /usr/local/bin; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
fi
done
if ! command -v git-lfs >/dev/null 2>&1; then
brew install git-lfs
fi
git-lfs --version
- name: Checkout repository
uses: actions/checkout@v6
env:
GIT_LFS_SKIP_SMUDGE: 1
with:
persist-credentials: false
lfs: false
uses: actions/checkout@v4
- name: Setup SSH key
run: |
@@ -304,206 +181,111 @@ jobs:
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "$DO_DROPLET_IP" >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Download warmup binary
id: download-warmup
continue-on-error: true
uses: actions/download-artifact@v8
with:
name: warmup-binary
path: scripts/bin/
- name: Build warmup binary fallback
if: steps.download-warmup.outcome != 'success'
- name: Build warmup tool
run: |
echo "::warning::warmup-binary artifact was unavailable; rebuilding warmup binary before deploy"
./ci/github_actions/ensure_bazel_installed.sh
bazel build \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:all \
//ci:warmup_tar
bazel build //src/main/go/net/eagle0/warmup:warmup_linux_amd64
mkdir -p scripts/bin
tar -xf bazel-bin/ci/warmup_tar.tar -C scripts/bin --strip-components=1
cp bazel-bin/src/main/go/net/eagle0/warmup/warmup_linux_amd64_/warmup_linux_amd64 scripts/bin/warmup
- name: Copy config files to droplet
run: |
# 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
mkdir -p /opt/eagle0/scripts/bin /opt/eagle0/nginx /opt/eagle0/deploy
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/deploy-blue-green.sh scripts/warmup-eagle.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/
scp -i ~/.ssh/deploy_key scripts/bin/warmup deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/bin/
scp -i ~/.ssh/deploy_key deploy/env.template deploy/update-env.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/deploy/
- name: Deploy to production droplet
run: |
ssh -i ~/.ssh/deploy_key deploy@"$DO_DROPLET_IP" bash -s << DEPLOY_SCRIPT
set -e
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
validate_env "EAGLE_POSTGRES_HOST" "${EAGLE_POSTGRES_HOST}" "" || VALIDATION_FAILED=1
validate_env "EAGLE_POSTGRES_DATABASE" "${EAGLE_POSTGRES_DATABASE}" "" || VALIDATION_FAILED=1
validate_env "EAGLE_POSTGRES_USER" "${EAGLE_POSTGRES_USER}" "" || VALIDATION_FAILED=1
validate_env "EAGLE_POSTGRES_PASSWORD" "${EAGLE_POSTGRES_PASSWORD}" "" || 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 EAGLE_HISTORY_BACKEND="${EAGLE_HISTORY_BACKEND:-postgres}"
export EAGLE_POSTGRES_HOST="${EAGLE_POSTGRES_HOST}"
export EAGLE_POSTGRES_PORT="${EAGLE_POSTGRES_PORT}"
export EAGLE_POSTGRES_DATABASE="${EAGLE_POSTGRES_DATABASE}"
export EAGLE_POSTGRES_USER="${EAGLE_POSTGRES_USER}"
export EAGLE_POSTGRES_PASSWORD="${EAGLE_POSTGRES_PASSWORD}"
export DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
export NOTIFY_SECRET="${NOTIFY_SECRET}"
export GITHUB_TOKEN="${GITHUB_TOKEN_FOR_ADMIN}"
# Environment variables passed via heredoc
EAGLE_IMAGE="${EAGLE_IMAGE}"
ADMIN_IMAGE="${ADMIN_IMAGE}"
JFR_SIDECAR_IMAGE="${JFR_SIDECAR_IMAGE}"
OPENAI_API_KEY="${OPENAI_API_KEY}"
GPT_MODEL_NAME="${GPT_MODEL_NAME}"
EAGLE_ENABLE_S3="${EAGLE_ENABLE_S3}"
DO_SPACES_ACCESS_KEY="${DO_SPACES_ACCESS_KEY}"
DO_SPACES_SECRET_KEY="${DO_SPACES_SECRET_KEY}"
JWT_PRIVATE_KEY="${JWT_PRIVATE_KEY}"
DISCORD_CLIENT_ID="${DISCORD_CLIENT_ID}"
DISCORD_CLIENT_SECRET="${DISCORD_CLIENT_SECRET}"
GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID}"
GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET}"
SHARDOK_ADDRESS="${SHARDOK_ADDRESS}"
SHARDOK_AUTH_TOKEN="${SHARDOK_AUTH_TOKEN}"
SENTRY_DSN="${SENTRY_DSN}"
FASTMAIL_API_TOKEN="${FASTMAIL_API_TOKEN}"
FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
# 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
# Update env vars
chmod +x deploy/update-env.sh
cd deploy && ./update-env.sh \
"EAGLE_IMAGE=\${EAGLE_IMAGE}" \
"ADMIN_IMAGE=\${ADMIN_IMAGE}" \
"JFR_SIDECAR_IMAGE=\${JFR_SIDECAR_IMAGE}" \
"OPENAI_API_KEY=\${OPENAI_API_KEY}" \
"GPT_MODEL_NAME=\${GPT_MODEL_NAME:-gpt-4o}" \
"EAGLE_ENABLE_S3=\${EAGLE_ENABLE_S3:-false}" \
"DO_SPACES_ACCESS_KEY=\${DO_SPACES_ACCESS_KEY}" \
"DO_SPACES_SECRET_KEY=\${DO_SPACES_SECRET_KEY}" \
"JWT_PRIVATE_KEY=\${JWT_PRIVATE_KEY}" \
"DISCORD_CLIENT_ID=\${DISCORD_CLIENT_ID}" \
"DISCORD_CLIENT_SECRET=\${DISCORD_CLIENT_SECRET}" \
"GOOGLE_CLIENT_ID=\${GOOGLE_CLIENT_ID}" \
"GOOGLE_CLIENT_SECRET=\${GOOGLE_CLIENT_SECRET}" \
"SHARDOK_ADDRESS=\${SHARDOK_ADDRESS:-shardok:40042}" \
"SHARDOK_AUTH_TOKEN=\${SHARDOK_AUTH_TOKEN}" \
"SENTRY_DSN=\${SENTRY_DSN}" \
"FASTMAIL_API_TOKEN=\${FASTMAIL_API_TOKEN}" \
"FASTMAIL_FROM_EMAIL=\${FASTMAIL_FROM_EMAIL}" \
"FASTMAIL_FROM_NAME=\${FASTMAIL_FROM_NAME}"
cd ..
# 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
# Install crane for pulling OCI images
echo "Installing crane..."
rm -f crane
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
chmod +x crane
# 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
# Pull and load all images
echo "Pulling Eagle image..."
./crane pull "\${EAGLE_IMAGE}" eagle.tar && docker load -i eagle.tar && rm eagle.tar
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 "Pulling Admin image..."
./crane pull "\${ADMIN_IMAGE}" admin.tar && docker load -i admin.tar && rm admin.tar
echo "All images pulled and loaded successfully"
echo "Pulling JFR Sidecar image..."
./crane pull "\${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar && docker load -i jfr-sidecar.tar && rm jfr-sidecar.tar
# =================================================================
# 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)
# Pull other compose images
docker pull nginx:alpine || true
docker pull certbot/certbot || true
# 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
echo "All images pulled successfully"
# Stop local shardok container if running (now runs on Hetzner)
docker stop shardok-server 2>/dev/null || true
@@ -511,51 +293,20 @@ jobs:
# 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}"
# Ensure auth is running
docker compose -f docker-compose.prod.yml up -d auth
# 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
# Cleanup
docker container prune -f
docker image prune -f
# Remove images older than 24h (keeps recent images for rollback)
docker image prune -a -f --filter "until=24h" || true
DEPLOY_SCRIPT
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
-54
View File
@@ -1,54 +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'
- 'ci/github_actions/ensure_bazel_installed.sh'
- '.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: Ensure Git LFS available for checkout
run: |
COMMON_PATHS=(/opt/homebrew/bin /usr/local/bin)
for path in "${COMMON_PATHS[@]}"; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
export PATH="$path:$PATH"
fi
done
if command -v git-lfs >/dev/null 2>&1; then
git-lfs version
exit 0
fi
brew install git-lfs
git-lfs version
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: false
lfs: false
- name: Ensure Bazel installed
run: ./ci/github_actions/ensure_bazel_installed.sh
- name: Build Eagle server
run: bazel build //src/main/scala/net/eagle0/eagle:eagle_server
+39 -91
View File
@@ -5,91 +5,67 @@ on:
branches: [ "main" ]
paths:
- ".github/workflows/installer_build.yml"
- "ci/github_actions/ensure_bazel_installed.sh"
- "src/main/go/net/eagle0/clients/win/installer/**"
- "src/main/csharp/net/eagle0/clients/win/installer/**"
pull_request:
paths:
- ".github/workflows/installer_build.yml"
- "ci/github_actions/ensure_bazel_installed.sh"
- "src/main/go/net/eagle0/clients/win/installer/**"
- "src/main/csharp/net/eagle0/clients/win/installer/**"
workflow_dispatch:
permissions:
contents: read
actions: write # Required to delete artifacts after deploy
jobs:
build-installer:
runs-on: [self-hosted, bazel]
steps:
- name: Ensure Git LFS available for checkout
run: |
COMMON_PATHS=(/opt/homebrew/bin /usr/local/bin)
for path in "${COMMON_PATHS[@]}"; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
export PATH="$path:$PATH"
fi
done
if command -v git-lfs >/dev/null 2>&1; then
git-lfs version
exit 0
fi
brew install git-lfs
git-lfs version
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
persist-credentials: false
lfs: false
clean: false
- name: Ensure Bazel installed
run: ./ci/github_actions/ensure_bazel_installed.sh
- name: Setup .NET 8
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Build Go installer for Windows
- name: Inject manifest public key
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
if [ -n "$MANIFEST_PUBLIC_KEY" ]; then
CONFIG_FILE="src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/configuration.txt"
echo "manifest_public_key = $MANIFEST_PUBLIC_KEY" >> "$CONFIG_FILE"
echo "Injected manifest public key into configuration.txt"
cat "$CONFIG_FILE"
else
echo "MANIFEST_PUBLIC_KEY not set, skipping injection"
fi
# 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@v7
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'
@@ -97,24 +73,25 @@ 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 "installer_url=installer/EagleInstaller.exe" >> /tmp/installer_manifest.txt
echo "=== Manifest content ==="
echo "=== Installer manifest content ==="
cat /tmp/installer_manifest.txt
echo "========================"
echo "=================================="
# Write signing key to temp file (if available)
SIGNING_ARGS=""
@@ -127,37 +104,8 @@ jobs:
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
# Update the unified manifest (with optional signing)
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- installer /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"
# Cleanup
rm -f /tmp/manifest_signing_key
-363
View File
@@ -1,363 +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: write
env:
# Runner-specific build directory to allow parallel builds on multiple runners
EAGLE0_BUILD_DIR: /tmp/eagle0-${{ github.run_id }}
# Runner-specific keychain to avoid conflicts when multiple runners sign simultaneously
KEYCHAIN_NAME: ios-build-${{ github.run_id }}.keychain
jobs:
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, halfdan]
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
- name: Ensure Git LFS available for checkout
run: |
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH="${COMMON_PATHS}:${PATH}"
for path in /opt/homebrew/bin /usr/local/bin; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
fi
done
if ! command -v git-lfs >/dev/null 2>&1; then
brew install git-lfs
fi
git-lfs --version
- uses: actions/checkout@v6
env:
GIT_LFS_SKIP_SMUDGE: 1
with:
persist-credentials: false
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
env:
GITHUB_TOKEN: ${{ github.token }}
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@v7
with:
name: editor_ios.log
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_ios.log
retention-days: 3
- name: Package generated iOS project
if: success()
run: |
tar -czf "$RUNNER_TEMP/eagle0-ios-project.tar.gz" -C "$EAGLE0_BUILD_DIR" eagle0iOS
- name: Upload generated iOS project
if: success()
uses: actions/upload-artifact@v7
with:
name: eagle0-ios-project-${{ github.run_id }}
path: ${{ runner.temp }}/eagle0-ios-project.tar.gz
retention-days: 1
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
archive-and-upload:
needs: build-unity
runs-on: [self-hosted, macOS, testflight, halfdan]
steps:
- name: Ensure Git LFS available for checkout
run: |
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH="${COMMON_PATHS}:${PATH}"
for path in /opt/homebrew/bin /usr/local/bin; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
fi
done
if ! command -v git-lfs >/dev/null 2>&1; then
brew install git-lfs
fi
git-lfs --version
- uses: actions/checkout@v6
env:
GIT_LFS_SKIP_SMUDGE: 1
with:
persist-credentials: false
lfs: false
- name: Clean build directory
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
- name: Download generated iOS project
uses: actions/download-artifact@v8
with:
name: eagle0-ios-project-${{ github.run_id }}
path: ${{ runner.temp }}
- name: Extract generated iOS project
run: |
mkdir -p "$EAGLE0_BUILD_DIR"
tar -xzf "$RUNNER_TEMP/eagle0-ios-project.tar.gz" -C "$EAGLE0_BUILD_DIR"
- name: Delete generated iOS project artifact
env:
GH_TOKEN: ${{ github.token }}
run: |
artifact_id=$(gh api "repos/${{ github.repository }}/actions/artifacts" \
--paginate \
--jq '.artifacts[] | select(.name == "eagle0-ios-project-${{ github.run_id }}") | .id')
if [ -n "$artifact_id" ]; then
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id"
fi
- 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@v7
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 }}"
+56 -280
View File
@@ -14,33 +14,28 @@ on:
- "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/ensure_bazel_installed.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/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_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/github_actions/ensure_bazel_installed.sh"
- "ci/mac/**"
workflow_dispatch:
inputs:
@@ -50,121 +45,34 @@ on:
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, halfdan]
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
- name: Ensure Git LFS available for checkout
run: |
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH="${COMMON_PATHS}:${PATH}"
for path in /opt/homebrew/bin /usr/local/bin; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
fi
done
if ! command -v git-lfs >/dev/null 2>&1; then
brew install git-lfs
fi
git-lfs --version
- uses: actions/checkout@v6
env:
GIT_LFS_SKIP_SMUDGE: 1
- uses: actions/checkout@v4
with:
persist-credentials: false
lfs: false # Fetch LFS after checkout to avoid stale ref issues
clean: false # Library/ persists between runs on self-hosted runners
lfs: true
clean: true # Remove untracked files like old SparklePlugin.bundle
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: Pull LFS files
run: git lfs pull
- name: Fetch LFS files
env:
GITHUB_TOKEN: ${{ github.token }}
run: ./ci/github_actions/fetch_lfs.sh
- name: Ensure Unity version installed
run: ./ci/github_actions/ensure_unity_installed.sh mac
- name: Ensure Bazel installed
run: ./ci/github_actions/ensure_bazel_installed.sh
- name: Sync Bazel Xcode config
run: ./scripts/sync_bazel_xcode.sh
- name: Restore Library/
run: ./ci/github_actions/restore_library.sh
- name: Build Mac Unity
run: ./ci/github_actions/build_unity_mac.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC"
run: ./ci/github_actions/build_unity_mac.sh "/tmp/eagle0/eagle0MAC"
- name: Save build SHA for Bee/ cache invalidation
if: success()
run: |
mkdir -p src/main/csharp/net/eagle0/clients/unity/eagle0/Library
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: Persist Library/
run: ./ci/github_actions/persist_library.sh
- name: Check if should deploy
id: check-deploy
@@ -193,31 +101,18 @@ jobs:
echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12
# Delete any existing keychain from previous runs
security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true
security delete-keychain build.keychain 2>/dev/null || true
# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
security default-keychain -s "$KEYCHAIN_NAME"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
# Import certificate
echo "=== Importing certificate ==="
security import certificate.p12 -k "$KEYCHAIN_NAME" -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign -T /usr/bin/security
security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign
# Allow codesign to access keychain
echo "=== Setting key partition list ==="
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
# Add keychain to search list (required for codesign to find certificates)
echo "=== Adding keychain to search list ==="
security list-keychains -d user -s "$KEYCHAIN_NAME" login.keychain
# Debug: Check what's in the keychain after import
echo "=== Debug: Identities in build keychain ==="
KEYCHAIN_PATH="$HOME/Library/Keychains/$KEYCHAIN_NAME-db"
security find-identity -v -p codesigning "$KEYCHAIN_PATH" || true
echo "=== Debug: All available identities ==="
security find-identity -v -p codesigning || true
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
# Clean up
rm certificate.p12
@@ -228,7 +123,7 @@ jobs:
SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
run: |
chmod +x ./scripts/codesign_mac_app.sh
./scripts/codesign_mac_app.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app" "ci/mac/eagle0.entitlements"
./scripts/codesign_mac_app.sh "/tmp/eagle0/eagle0MAC/eagle0.app" "ci/mac/eagle0.entitlements"
- name: Submit for Notarization
id: notarize-submit
@@ -239,80 +134,59 @@ jobs:
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
chmod +x ./scripts/notarize_submit.sh
./scripts/notarize_submit.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app" >> $GITHUB_OUTPUT
./scripts/notarize_submit.sh "/tmp/eagle0/eagle0MAC/eagle0.app" >> $GITHUB_OUTPUT
- name: Cleanup Keychain
if: always()
run: |
security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true
security delete-keychain build.keychain 2>/dev/null || true
- name: Zip signed app for artifact
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
run: |
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
cd /tmp/eagle0/eagle0MAC
ditto -c -k --keepParent eagle0.app eagle0.app.zip
- name: Upload signed app
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: signed-mac-app-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app.zip
name: signed-mac-app
path: /tmp/eagle0/eagle0MAC/eagle0.app.zip
retention-days: 1
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v7
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 }}"
path: /tmp/eagle0/editor_mac.log
wait-notarization:
needs: build-and-sign
if: needs.build-and-sign.outputs.should_deploy == 'true'
runs-on: [self-hosted, macOS, notarize, halfdan]
runs-on: [self-hosted, macOS, notarize]
steps:
- name: Ensure Git LFS available for checkout
run: |
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH="${COMMON_PATHS}:${PATH}"
for path in /opt/homebrew/bin /usr/local/bin; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
fi
done
if ! command -v git-lfs >/dev/null 2>&1; then
brew install git-lfs
fi
git-lfs --version
- uses: actions/checkout@v6
env:
GIT_LFS_SKIP_SMUDGE: 1
- uses: actions/checkout@v4
with:
persist-credentials: false
sparse-checkout: scripts
- name: Clean download directory
run: rm -rf ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
run: rm -rf /tmp/eagle0/eagle0MAC
- name: Download signed app
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: signed-mac-app-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
name: signed-mac-app
path: /tmp/eagle0/eagle0MAC
- name: Unzip signed app
run: |
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
cd /tmp/eagle0/eagle0MAC
ditto -x -k eagle0.app.zip .
rm eagle0.app.zip
ls -la ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app/
ls -la /tmp/eagle0/eagle0MAC/eagle0.app/
- name: Wait for Notarization and Staple
env:
@@ -321,153 +195,55 @@ jobs:
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
chmod +x ./scripts/notarize_wait.sh
./scripts/notarize_wait.sh "${{ needs.build-and-sign.outputs.submission_id }}" "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app"
./scripts/notarize_wait.sh "${{ needs.build-and-sign.outputs.submission_id }}" "/tmp/eagle0/eagle0MAC/eagle0.app"
- name: Zip notarized app for artifact
run: |
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
cd /tmp/eagle0/eagle0MAC
rm -f eagle0.app.zip
ditto -c -k --keepParent eagle0.app eagle0.app.zip
- name: Upload notarized app
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: notarized-mac-app-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app.zip
name: notarized-mac-app
path: /tmp/eagle0/eagle0MAC/eagle0.app.zip
retention-days: 1
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
deploy:
needs: [build-and-sign, wait-notarization]
if: needs.build-and-sign.outputs.should_deploy == 'true'
runs-on: [self-hosted, macOS, notarize, halfdan]
outputs:
deployed_version: ${{ steps.deploy-mac.outputs.deployed_version }}
runs-on: [self-hosted, macOS, unity-mac]
steps:
- name: Ensure Git LFS available for checkout
run: |
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH="${COMMON_PATHS}:${PATH}"
for path in /opt/homebrew/bin /usr/local/bin; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
fi
done
if ! command -v git-lfs >/dev/null 2>&1; then
brew install git-lfs
fi
git-lfs --version
- uses: actions/checkout@v6
env:
GIT_LFS_SKIP_SMUDGE: 1
- uses: actions/checkout@v4
with:
persist-credentials: false
fetch-depth: 0 # For version numbering
- name: Clean download directory
run: rm -rf ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
run: rm -rf /tmp/eagle0/eagle0MAC
- name: Download notarized app
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: notarized-mac-app-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
name: notarized-mac-app
path: /tmp/eagle0/eagle0MAC
- name: Unzip notarized app
run: |
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
cd /tmp/eagle0/eagle0MAC
ditto -x -k eagle0.app.zip .
rm eagle0.app.zip
- name: Ensure Bazel installed
run: ./ci/github_actions/ensure_bazel_installed.sh
- 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")
VERSION=$(git describe --tags --always)
BUILD_NUMBER=$(git rev-list --count HEAD)
bazel run //src/main/go/net/eagle0/build/mac_build_handler:mac_build_handler -- \
"${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app" \
"/tmp/eagle0/eagle0MAC/eagle0.app" \
"$VERSION" \
"$BUILD_NUMBER" \
"$BACKGROUND_PATH" \
"$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"
"$BUILD_NUMBER"
+12 -27
View File
@@ -39,33 +39,33 @@ jobs:
echo "Dry run: ${DRY_RUN}"
echo ""
# List repositories via JSON. Text output can wrap table rows and make
# headers/tags/digests look like repository names.
REPOS=$(doctl registry repository list-v2 "${REGISTRY}" --output json | jq -r '.[] | .name // .Name // empty')
# 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 "[]")
# Get all manifests with their tags and dates
# Filter out header row and empty lines
MANIFESTS=$(doctl registry repository list-manifests "${REPO}" --format Digest,UpdatedAt,Tags --no-header 2>/dev/null | grep -v '^Digest' | grep -v '^$' || echo "")
if [ "$MANIFESTS_JSON" = "[]" ] || [ -z "$MANIFESTS_JSON" ]; then
if [ -z "$MANIFESTS" ]; 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
echo "$MANIFESTS" | 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)
# Parse the date
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
if echo "$TAGS" | grep -qE '(^|,)(latest|arm64-latest)(,|$)'; then
echo " KEEP: ${DIGEST:0:20}... (protected tag: $TAGS)"
continue
fi
@@ -88,20 +88,5 @@ jobs:
if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'false')
run: |
echo "Starting garbage collection..."
set +e
GC_OUTPUT=$(doctl registry garbage-collection start --force 2>&1)
GC_STATUS=$?
set -e
echo "$GC_OUTPUT"
if [ "$GC_STATUS" -eq 0 ]; then
echo "Garbage collection started. It may take a few minutes to complete."
exit 0
fi
if echo "$GC_OUTPUT" | grep -q "automated garbage collection is enabled"; then
echo "Automated garbage collection is enabled for this registry; skipping manual garbage collection."
exit 0
fi
exit "$GC_STATUS"
doctl registry garbage-collection start --force
echo "Garbage collection started. It may take a few minutes to complete."
-86
View File
@@ -1,86 +0,0 @@
name: S3 Archive Cleanup
on:
schedule:
# Run weekly on Sunday at 05:00 UTC
- cron: '0 5 * * 0'
workflow_dispatch: # Allow manual trigger
permissions:
contents: read
jobs:
cleanup:
runs-on: [self-hosted, bazel, halfdan]
steps:
- name: Ensure AWS CLI is available
run: |
if ! command -v aws &> /dev/null; then
brew install awscli
fi
- name: Delete old archived game folders
env:
AWS_ACCESS_KEY_ID: ${{ secrets.DO_SPACES_ACCESS_KEY }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DO_SPACES_SECRET_KEY }}
run: |
set -euo pipefail
S3_ENDPOINT="https://sfo3.digitaloceanspaces.com"
BUCKET="s3://eagle0/eagle/archived/"
# macOS date syntax
CUTOFF=$(date -v-1m +%s)
DELETED=0
SKIPPED=0
NO_DIR_FILE=0
echo "Cutoff date: $(date -r ${CUTOFF} '+%Y-%m-%dT%H:%M:%S')"
echo ""
FOLDERS=$(aws s3 ls "$BUCKET" --endpoint-url "$S3_ENDPOINT" \
| awk '/PRE/{gsub(/\/$/,"",$2); print $2}')
if [ -z "$FOLDERS" ]; then
echo "No archived folders found."
exit 0
fi
TOTAL=$(echo "$FOLDERS" | wc -l | tr -d ' ')
CURRENT=0
for game_id in $FOLDERS; do
CURRENT=$((CURRENT + 1))
DIR_INFO=$(aws s3 ls "${BUCKET}${game_id}/directory.e0i" \
--endpoint-url "$S3_ENDPOINT" 2>/dev/null || true)
if [ -z "$DIR_INFO" ]; then
echo "[$CURRENT/$TOTAL] DELETE $game_id (no directory.e0i)"
aws s3 rm --recursive "${BUCKET}${game_id}/" \
--endpoint-url "$S3_ENDPOINT" > /dev/null 2>&1
DELETED=$((DELETED + 1))
NO_DIR_FILE=$((NO_DIR_FILE + 1))
continue
fi
FILE_DATE=$(echo "$DIR_INFO" | awk '{print $1 " " $2}')
FILE_EPOCH=$(date -j -f '%Y-%m-%d %H:%M:%S' "$FILE_DATE" +%s 2>/dev/null || echo "0")
if [ "$FILE_EPOCH" -lt "$CUTOFF" ]; then
echo "[$CURRENT/$TOTAL] DELETE $game_id (directory.e0i from $FILE_DATE)"
aws s3 rm --recursive "${BUCKET}${game_id}/" \
--endpoint-url "$S3_ENDPOINT" > /dev/null 2>&1
DELETED=$((DELETED + 1))
else
echo "[$CURRENT/$TOTAL] KEEP $game_id (directory.e0i from $FILE_DATE)"
SKIPPED=$((SKIPPED + 1))
fi
done
echo ""
echo "=== Summary ==="
echo "Total folders: $TOTAL"
echo "Deleted: $DELETED"
echo "Kept (recent): $SKIPPED"
+26 -74
View File
@@ -9,9 +9,7 @@ on:
- 'src/main/protobuf/net/eagle0/common/**'
- 'src/main/resources/net/eagle0/shardok/**'
- 'ci/BUILD.bazel'
- 'ci/github_actions/ensure_bazel_installed.sh'
- 'MODULE.bazel'
- '.bazelrc'
- '.github/workflows/shardok_arm64_build.yml'
workflow_dispatch:
inputs:
@@ -21,28 +19,20 @@ on:
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, halfdan]
runs-on: [self-hosted, bazel]
outputs:
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
persist-credentials: false
lfs: false
- name: Ensure Bazel installed
run: ./ci/github_actions/ensure_bazel_installed.sh
- name: Build Shardok ARM64 binary (cross-compile for Linux ARM64)
run: |
set -ex
@@ -69,14 +59,12 @@ jobs:
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
if [ "$E_MACHINE" = "b700" ]; then
echo "SUCCESS: Binary is ARM64 (aarch64)"
else
echo "WARNING: Binary e_machine is $E_MACHINE (expected 00b7 for aarch64)"
echo "WARNING: Binary e_machine is $E_MACHINE (expected b700 for aarch64)"
fi
elif [ "$MAGIC" = "cfaeedfe" ] || [ "$MAGIC" = "cffaedfe" ]; then
echo "ERROR: Binary is Mach-O format (macOS) - cross-compilation failed!"
@@ -139,19 +127,20 @@ jobs:
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"
# Build a push target to get crane in runfiles
bazel build //ci:eagle_server_push
# Find the Darwin crane binary
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
if [ -z "$CRANE" ]; then
CRANE=$(find "$RUNFILES" -name crane 2>/dev/null | head -1)
fi
if [ -z "$CRANE" ] || [ ! -e "$CRANE" ]; then
echo "ERROR: crane not found"
exit 1
fi
echo "Using crane: $CRANE"
# Push with arm64-prefixed SHA tag (same repo as x86, different tag)
GIT_SHA=$(git rev-parse --short=8 HEAD)
@@ -170,7 +159,7 @@ jobs:
echo "Also tagged as: registry.digitalocean.com/eagle0/shardok-server:arm64-latest"
deploy-hetzner:
runs-on: [self-hosted, bazel, halfdan]
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
@@ -185,55 +174,19 @@ jobs:
# Add host key to known_hosts to avoid prompt
ssh-keyscan -H ${{ secrets.HETZNER_IP }} >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Pull Shardok image tarball for Hetzner
env:
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
set -ex
# Hetzner is IPv6-only, while Docker can be redirected to IPv4-only
# DigitalOcean registry/blob endpoints. Pull on the runner and copy
# the Docker-loadable tarball over SSH instead.
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
AUTH=$(echo -n "${DO_REGISTRY_TOKEN}:${DO_REGISTRY_TOKEN}" | base64)
mkdir -p ~/.docker
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
IMAGE_TAR="${RUNNER_TEMP}/shardok-arm64-image.tar"
"$CRANE" pull "${SHARDOK_IMAGE}" "$IMAGE_TAR"
echo "SHARDOK_IMAGE_TAR=$IMAGE_TAR" >> "$GITHUB_ENV"
- name: Copy Shardok image tarball to Hetzner
run: |
set -ex
ssh -i ~/.ssh/hetzner_deploy -o StrictHostKeyChecking=accept-new deploy@${{ secrets.HETZNER_IP }} \
"cat > /tmp/shardok-arm64-image.tar" < "$SHARDOK_IMAGE_TAR"
- name: Deploy to Hetzner
run: |
ssh -i ~/.ssh/hetzner_deploy -o StrictHostKeyChecking=accept-new deploy@${{ secrets.HETZNER_IP }} << 'ENDSSH'
set -ex
cd /opt/eagle0
SHARDOK_IMAGE="${{ needs.build-shardok-arm64.outputs.image_tag }}"
echo "Loading Shardok ARM64 image: $SHARDOK_IMAGE"
docker load -i /tmp/shardok-arm64-image.tar
rm -f /tmp/shardok-arm64-image.tar
docker image inspect "$SHARDOK_IMAGE" > /dev/null
echo "Loaded image ID: $(docker image inspect --format '{{.Id}}' "$SHARDOK_IMAGE")"
echo "Shardok binary SHA256:"
docker run --rm --entrypoint sha256sum "$SHARDOK_IMAGE" /app/shardok-server
# 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 }}"
# Pull the new image
docker pull "${{ needs.build-shardok-arm64.outputs.image_tag }}"
# Stop and remove any container using port 40042 or named shardok*
docker ps -q --filter "publish=40042" | xargs -r docker stop
@@ -251,12 +204,11 @@ jobs:
-v /usr/local/share/eagle0:/usr/local/share/eagle0:ro \
-e SHARDOK_RESOURCES_PATH=/app/resources \
-e SHARDOK_MAPS_PATH=/app/resources/maps \
"$SHARDOK_IMAGE"
"${{ needs.build-shardok-arm64.outputs.image_tag }}"
# Wait and verify
sleep 5
docker ps | grep shardok-ai
echo "Running container image ID: $(docker inspect --format '{{.Image}}' shardok-ai)"
# Cleanup old images
docker image prune -f
+15 -31
View File
@@ -1,23 +1,27 @@
name: Shardok Build
on:
# Main pushes are covered by shardok_arm64_build.yml which builds the same target
pull_request:
push:
branches: [ "main" ]
paths:
- 'src/main/cpp/**'
- 'src/main/protobuf/net/eagle0/shardok/**'
- 'src/main/protobuf/net/eagle0/common/**'
- '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/**'
- 'src/main/proto/net/eagle0/shardok/**'
- 'src/main/proto/net/eagle0/common/**'
- 'src/main/go/net/eagle0/build/**'
- 'WORKSPACE'
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.bazelrc'
- 'ci/github_actions/ensure_bazel_installed.sh'
- '.github/workflows/shardok_build.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
permissions:
contents: read
@@ -27,29 +31,9 @@ jobs:
runs-on: [self-hosted, bazel]
steps:
- name: Ensure Git LFS available for checkout
run: |
COMMON_PATHS=(/opt/homebrew/bin /usr/local/bin)
for path in "${COMMON_PATHS[@]}"; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
export PATH="$path:$PATH"
fi
done
if command -v git-lfs >/dev/null 2>&1; then
git-lfs version
exit 0
fi
brew install git-lfs
git-lfs version
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v4
with:
persist-credentials: false
lfs: false
- name: Ensure Bazel installed
run: ./ci/github_actions/ensure_bazel_installed.sh
- name: Build Shardok server
run: bazel build -c opt //src/main/cpp/net/eagle0/shardok:shardok-server
+22 -160
View File
@@ -15,11 +15,8 @@ on:
- "scripts/build_plugins.sh"
- "scripts/build_windows_plugin.sh"
- "ci/github_actions/build_unity.sh"
- "ci/github_actions/test_unity_editmode.sh"
- "ci/github_actions/ensure_bazel_installed.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:
@@ -36,141 +33,40 @@ on:
- "scripts/build_plugins.sh"
- "scripts/build_windows_plugin.sh"
- "ci/github_actions/build_unity.sh"
- "ci/github_actions/test_unity_editmode.sh"
- "ci/github_actions/ensure_bazel_installed.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' }}
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, halfdan]
outputs:
deployed_version: ${{ steps.get-version.outputs.deployed_version }}
runs-on: [self-hosted, macOS, unity-windows]
steps:
- name: Prune stale PR refs
run: |
# Self-hosted runners persist .git between runs. When a PR is updated,
# old local refs (refs/remotes/pull/*/merge) may point to commits whose
# objects were never fetched or have been pruned. Remove these stale refs
# before checkout to prevent "missing object" errors.
if [ -d ".git" ]; then
echo "Pruning stale PR refs..."
git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
xargs -r git update-ref -d 2>/dev/null || true
fi
- name: Ensure Git LFS available for checkout
run: |
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH="${COMMON_PATHS}:${PATH}"
for path in /opt/homebrew/bin /usr/local/bin; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
fi
done
if ! command -v git-lfs >/dev/null 2>&1; then
brew install git-lfs
fi
git-lfs --version
- uses: actions/checkout@v6
env:
GIT_LFS_SKIP_SMUDGE: 1
- uses: actions/checkout@v4
with:
persist-credentials: false
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
LIBRARY_DIR="src/main/csharp/net/eagle0/clients/unity/eagle0/Library"
VERSION_CACHE="$LIBRARY_DIR/.last_unity_version"
PROJECT_VERSION_FILE="src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
CURRENT_VERSION=$(grep "m_EditorVersion:" "$PROJECT_VERSION_FILE" | head -1 | sed 's/m_EditorVersion: //')
# Nuke Library/ when Unity version changes to avoid import loops
if [ -f "$VERSION_CACHE" ]; then
CACHED_VERSION=$(cat "$VERSION_CACHE")
if [ "$CACHED_VERSION" != "$CURRENT_VERSION" ]; then
echo "Unity version changed ($CACHED_VERSION -> $CURRENT_VERSION) -- clearing Library/"
rm -rf "$LIBRARY_DIR"
fi
fi
# 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="$LIBRARY_DIR/Bee"
SHA_FILE="$LIBRARY_DIR/.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
env:
GITHUB_TOKEN: ${{ github.token }}
run: ./ci/github_actions/fetch_lfs.sh
- name: Ensure Unity version installed
run: ./ci/github_actions/ensure_unity_installed.sh windows
- name: Ensure Bazel installed
run: ./ci/github_actions/ensure_bazel_installed.sh
- name: Run Unity EditMode tests
run: ./ci/github_actions/test_unity_editmode.sh
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: |
mkdir -p src/main/csharp/net/eagle0/clients/unity/eagle0/Library
git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
- name: Save Unity version for Library/ cache invalidation
if: success()
run: |
mkdir -p src/main/csharp/net/eagle0/clients/unity/eagle0/Library
grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //' > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_unity_version
- 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 }}
@@ -187,48 +83,14 @@ jobs:
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
# Update the unified manifest (with optional signing)
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- unity3d /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
- name: Archive build log
if: success() || failure()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: editor_win.log
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_win.log
retention-days: 3
- name: Archive EditMode test artifacts
if: success() || failure()
uses: actions/upload-artifact@v7
with:
name: unity_editmode_tests
path: |
${{ env.EAGLE0_BUILD_DIR }}/editor_editmode_tests.log
${{ env.EAGLE0_BUILD_DIR }}/editmode-test-results.xml
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
-7
View File
@@ -23,8 +23,6 @@ bazel-bin
bazel-eagle0*
bazel-out
bazel-testlogs
.bazelrc.local
.bazelrc.xcode
.ijwb
.clwb
buildWin.sh
@@ -39,10 +37,5 @@ 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/
src/main/csharp/net/eagle0/clients/unity/eagle0/docs/generated/unity_asset_usage_audit_files.csv
src/main/csharp/net/eagle0/clients/unity/eagle0/docs/generated/unity_lfs_asset_audit.csv
+3 -13
View File
@@ -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:
@@ -34,8 +24,8 @@ repos:
hooks:
- id: scalafmt
name: scalafmt
language: system
entry: ./scripts/pre-commit-scalafmt.sh
language: system
entry: scalafmt -i -f
types_or: ["scala"]
- repo: local
hooks:
-380
View File
@@ -1,380 +0,0 @@
# AGENTS.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.
**NEVER prefix a command with `cd`.** Run `git`, `gh`, `bazel`, etc. directly from whatever the cwd already is. The cwd is a subdirectory of the worktree; `git`/`gh` find the repo via `.git` discovery and `bazel` finds the workspace via MODULE.bazel discovery — walking up from a subdir works fine. The shell resets cwd after every command anyway, so a `cd` never persists. (And `cd <dir> && <cmd>` violates the no-chaining rule above and forces an approval prompt every time.) Only `cd` in the rare case a tool genuinely cannot locate its root, and even then as its own Bash call, never a chain.
## CRITICAL GIT RULES (NEVER VIOLATE)
**NEVER use `git -C`.** Just run `git` directly from the cwd — it finds the repo via `.git` discovery. Do not `cd` to the repo root either (see the no-`cd` bash rule above).
**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.
## Worktree Usage
Prefer using the current worktree for small requested fixes. Create a separate worktree only when the user asks for one,
when the current branch/state would make the change unsafe, or when isolating a large/risky change is clearly beneficial.
**NEVER leave the current worktree without explicit user approval.** Do not create, switch to, edit, commit in, or run
git commands from another worktree unless the user has specifically asked for that worktree or approved the move. If the
current worktree is unsafe because of dirty state, report that and ask before using a different worktree.
Codex may edit code and run git commands in the current worktree, even when unrelated files are dirty, as long as it:
- does not revert, reset, clean, or overwrite unrelated changes
- stages only the files relevant to the requested change
- verifies the staged diff before committing
- creates feature branches and PRs as usual
- does not push to main/master or merge PRs
When the user says a Codex-created PR has been merged, delete the corresponding local branch and temporary worktree
automatically, unless there is uncommitted work that would make cleanup unsafe.
## PR Timing
When code changes appear correct locally, create the PR promptly even if long-running builds or tests are still running.
Opening the PR starts CI on the build machines, uploads results to the Bazel remote cache, and can speed up subsequent
local validation. Continue monitoring any already-started local and CI validation after opening the PR.
---
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project Overview
Eagle0 is a multi-language gaming system combining strategic turn-based gameplay (Eagle) with tactical hex-based
combat (Shardok). The system integrates LLM-based narrative generation and supports both human and AI players.
## Architecture
**Three-Tier Game System:**
- **Unity Client (C#)**: Real-time strategy game client with integrated tactical combat UI
- **Eagle (Scala)**: Strategic layer managing turn-based gameplay, diplomacy, hero progression, and province control
- **Shardok (C++)**: Tactical layer handling real-time hex-based combat simulation with performance-critical battle
resolution
**Communication Flow:**
```
Unity Client ↔ Eagle (gRPC streaming) ↔ Shardok (internal gRPC)
```
**Key Entry Points:**
- `/src/main/csharp/net/eagle0/clients/unity/eagle0/` - Unity C# game client
- `/src/main/scala/net/eagle0/eagle/Main.scala` - Eagle strategic game server
- `/src/main/cpp/net/eagle0/shardok/shardok_server_main.cpp` - Shardok tactical server
**Protocol Buffer Architecture:**
- Extensive use of protobuf for type-safe communication
- Separate packages: `api/` (client-facing), `internal/` (server state), `views/` (client projections)
- Event sourcing pattern with immutable action history
## Essential Commands
### Building
```bash
# Build Eagle server (Scala strategic layer)
bazel build //src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar
# Build Shardok server (C++ tactical layer)
bazel build -c opt //src/main/cpp/net/eagle0/shardok:shardok-server
# Shardok server includes both AI algorithms
bazel build //src/main/cpp/net/eagle0/shardok:shardok-server
# Build Unity/C# client
./scripts/build_protos.sh # Protocol buffer generation for Unity
./scripts/build_plugins.sh # Native plugins for all platforms
./scripts/build_windows_plugin.sh # Windows-specific plugin build
# Unity builds via CI: ci/github_actions/build_unity.sh
```
### Running Services
```bash
# Eagle server (port 40032)
bazel run //src/main/scala/net/eagle0/eagle:eagle_server -- --eagle-grpc-port 40032
# Or: ./scripts/eagle_run.sh
# Shardok server
bazel run //src/main/cpp/net/eagle0/shardok:shardok-server --compilation_mode=opt
# Or: ./scripts/shardok_run.sh
```
### Testing
```bash
# Run all tests
bazel test //src/test/... //src/main/go/...
# Component-specific tests
bazel test //src/test/scala/... # Scala Eagle tests
bazel test //src/test/cpp/... # C++ Shardok tests
```
### Code Generation
```bash
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
# ALWAYS run clang-format after making any C++ or C# code changes
clang-format -i <modified_files>
# Format all C++ files in a directory:
find . -name "*.cpp" -o -name "*.hpp" | xargs clang-format -i
# Format all C# files in a directory:
find . -name "*.cs" | xargs clang-format -i
```
### Static Analysis
```bash
# Run clang-tidy static analysis on C++ files
# Note: This may show some header include errors but will still analyze the main file
bazel run @llvm_toolchain//:clang-tidy -- --checks='readability-*,bugprone-*,clang-analyzer-*' <file_path> -- -I/Users/dancrosby/CodingProjects/github/eagle0 -std=c++23
# Example for AI files:
bazel run @llvm_toolchain//:clang-tidy -- --checks='readability-*,bugprone-*,clang-analyzer-*' /Users/dancrosby/CodingProjects/github/eagle0/src/main/cpp/net/eagle0/shardok/ai/AIUnitScoreCalculator.cpp -- -I/Users/dancrosby/CodingProjects/github/eagle0 -std=c++23
```
## AI Algorithm Selection
Eagle0 supports two AI algorithms for tactical combat decision-making:
### Iterative Deepening AI (Default)
The original minimax-based AI with sophisticated randomness handling:
- **Advantages**: Proven, sophisticated randomness evaluation, comprehensive lookahead
- **Use cases**: Production builds, scenarios requiring precise evaluation
- **Performance**: Single-threaded, thorough evaluation
### Monte Carlo Tree Search AI (MCTS)
Modern MCTS-based AI with multithreading support:
- **Advantages**: Multithreaded, better performance on modern CPUs, anytime algorithm
- **Use cases**: Performance testing, scenarios requiring fast decisions
- **Performance**: Multithreaded, adaptive depth based on time budget
### Switching Between Algorithms
The algorithm is selected at **runtime** via the ShardokAIClient constructor:
```cpp
// Using Iterative Deepening AI (default)
ShardokAIClient client(playerId, isDefender, hexMap, settings);
// OR explicitly:
ShardokAIClient client(playerId, isDefender, hexMap, settings, AIAlgorithmType::ITERATIVE_DEEPENING);
// Using MCTS AI
ShardokAIClient client(playerId, isDefender, hexMap, settings, AIAlgorithmType::MCTS);
```
```bash
# Build the server (includes both AI algorithms)
bazel build //src/main/cpp/net/eagle0/shardok:shardok-server
# Test both algorithms
bazel test //src/test/cpp/net/eagle0/shardok/ai:ai_iterative_deepening_test
bazel test //src/test/cpp/net/eagle0/shardok/ai:ai_mcts_test # If available
# Performance tests
./scripts/ai_perf_test.sh # Uses whatever algorithm the server is configured to use
```
Both implementations are compatible with all existing interfaces and produce the same `SearchResult` structure.
**Note**: Both implementations are documented in `src/main/cpp/net/eagle0/shardok/ai/AI_SCORING_SYSTEM.md`, including
recommendations for improving MCTS randomness handling.
The AI algorithm selection is made at runtime when creating ShardokAIClient instances, allowing different AI strategies
to be used for different players or game situations within the same server process.
## Language-Specific Patterns
**Scala (Strategic Layer):**
- Use `EngineImpl.scala` for core game logic modifications
- Follow event sourcing pattern - all changes through immutable actions
- gRPC streaming for real-time client updates via `EagleServiceImpl.scala`
- LLM integration in `/common/llm_integration/` for narrative generation
**C++ (Tactical Layer):**
- Performance-critical combat in `ShardokEngine.hpp/.cpp`
- FlatBuffers for efficient serialization in `/flatbuffer/` directory
- AI systems in `/ai/` subdirectory with pluggable strategy selectors
- Extensive unit testing with Google Test framework
**Protocol Buffers:**
- Three-layer structure: `api/` (client), `internal/` (server), `views/` (projections)
- Use `shardok_internal_interface.proto` for Eagle-Shardok communication
- Maintain backward compatibility when modifying existing messages
**C# (Unity Client):**
- Located in `/src/main/csharp/net/eagle0/clients/unity/eagle0/`
- Uses Unity 6 (6000.4.9f1) with comprehensive protobuf integration (100+ .proto files)
- Key components: `EagleConnection.cs` (gRPC client), `EagleGameController.cs` (main game logic)
- 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):**
- Build automation and code generation utilities
- AWS S3 integration for deployment artifacts
## Testing Strategy
- Comprehensive unit tests for both Scala and C++ components
- Integration tests for Eagle-Shardok communication
- 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:
```bash
# 1. Commit your changes to a feature branch
git checkout -b performance-improvement-feature
git add . && git commit -m "Implement performance improvement"
# 2. Run performance tests multiple times on your branch to reduce noise
for i in 1 2 3; do
echo "=== Run $i ==="
./scripts/ai_perf_test.sh 2>&1 | grep -A 20 "AI Search Performance Summary"
done
# Save or note the results
# 3. Switch to main branch and run the same tests
git checkout main
for i in 1 2 3; do
echo "=== Run $i ==="
./scripts/ai_perf_test.sh 2>&1 | grep -A 20 "AI Search Performance Summary"
done
# 4. Compare the results between your branch and main
# Key metrics to compare:
# - Commands evaluated at each depth (e.g., "Depth 3: 169/523 commands")
# - Average search depth achieved
# - Completion rates at each depth
```
**Important notes:**
- Run tests multiple times (3-5) to account for performance variance
- Focus on commands evaluated at each depth rather than total commands
- Commands at different depths aren't directly comparable (depth 3 is more valuable than depth 2)
- **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
- CI/CD via GitHub Actions with platform-specific build scripts in `/ci/github_actions/`
- Docker containerization available via `ci/eagle_run.Dockerfile`
- Always run "bazel run //:gazelle" after editing any BUILD.bazel files
- *ALWAYS ALWAYS* run "bazel run gazelle" after any change that modifies a BUILD.bazel file
-12
View File
@@ -32,15 +32,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",
],
)
+2 -19
View File
@@ -1,15 +1,7 @@
# 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.
**NEVER prefix a command with `cd`.** Run `git`, `gh`, `bazel`, etc. directly from whatever the cwd already is. The cwd is a subdirectory of the worktree; `git`/`gh` find the repo via `.git` discovery and `bazel` finds the workspace via MODULE.bazel discovery — walking up from a subdir works fine. The shell resets cwd after every command anyway, so a `cd` never persists. (And `cd <dir> && <cmd>` violates the no-chaining rule above and forces an approval prompt every time.) Only `cd` in the rare case a tool genuinely cannot locate its root, and even then as its own Bash call, never a chain.
## CRITICAL GIT RULES (NEVER VIOLATE)
**NEVER use `git -C`.** Just run `git` directly from the cwd — it finds the repo via `.git` discovery. Do not `cd` to the repo root either (see the no-`cd` bash rule above).
**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.
@@ -118,10 +110,6 @@ bazel run gazelle # Update Go build files
1. **If you modified any BUILD.bazel file:** Run `bazel run gazelle` and stage any changes it makes
2. **If you modified C++ or C# files:** Run `clang-format -i` on the modified files
3. **If you modified Scala files:** scalafmt will run automatically via pre-commit hook
4. **ALWAYS run the full test suite for your language changes before pushing:**
- For Scala changes: `bazel test //src/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.**
@@ -228,14 +216,11 @@ to be used for different players or game situations within the same server proce
**C# (Unity Client):**
- Located in `/src/main/csharp/net/eagle0/clients/unity/eagle0/`
- Uses Unity 6 (6000.4.9f1) with comprehensive protobuf integration (100+ .proto files)
- Uses Unity 6 (6000.0.32f1) with comprehensive protobuf integration (100+ .proto files)
- Key components: `EagleConnection.cs` (gRPC client), `EagleGameController.cs` (main game logic)
- 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):**
@@ -344,12 +329,10 @@ dotty.tools.dotc.core.MissingType: Cannot resolve reference to type net.eagle0.e
**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
- CI/CD via GitHub Actions with platform-specific build scripts in `/ci/github_actions/`
- Docker containerization available via `ci/eagle_run.Dockerfile`
- Always run "bazel run //:gazelle" after editing any BUILD.bazel files
- *ALWAYS ALWAYS* run "bazel run gazelle" after any change that modifies a BUILD.bazel file
- *ALWAYS ALWAYS* run "bazel run gazelle" after any change that modifies a BUILD.bazel file
+566
View File
@@ -0,0 +1,566 @@
# Deproto Migration Plan
This document tracks the migration from protobuf types to native Scala models inside the Eagle game engine.
## Architectural Decisions
1. **Keep proto for persistence**: Yes - protobuf is used for persisting game state
2. **Keep proto for Shardok communication**: Yes - protobuf is used for Eagle-Shardok gRPC communication
3. **Use Scala views inside the library**: Yes - use native Scala types like `ProvinceView`, `FactionView`, `HeroView`, etc. within the library code
## Recent Completed Work
### GameState Round-Trip Elimination (PRs #4913, #4914, #4915)
Eliminated wasteful Scala→proto→Scala conversions in the hot path:
1. **LLM Pipeline** (#4913): `LlmRequestWithGameState` now uses Scala `GameState` instead of proto. All ~38 prompt generators updated to use Scala model types (`FactionT`, `HeroT`, `ProvinceT`).
2. **ActionWithResultingState Caching** (#4914): Added `precomputedScalaState: Option[GameState]` to cache Scala state when available, avoiding `fromProto()` conversion in `stateAfter()`.
3. **PostResults Simplification** (#4915): Changed `PostResults.gameState` from proto to `Option[GameState]` (Scala), eliminating `toProto()` calls when creating PostResults.
## Migration Pattern
The codebase follows a **Legacy* pattern** for separating proto-dependent and protoless code:
- **Protoless utilities**: `FactionUtils`, `HeroUtils`, `ProvinceUtils`, `ProvinceDistances`, etc.
- **Proto-dependent utilities**: `LegacyFactionUtils`, `LegacyHeroUtils`, `LegacyProvinceUtils`, `LegacyProvinceDistances`, etc.
When migrating a file:
1. Create a `Legacy*` version containing the proto-dependent methods
2. Keep the original file name for protoless methods
3. Update callers to use the appropriate version based on their context
## Migration Status
### Fully Protoless (no proto imports)
**Utilities:**
- [x] `FactionUtils` - has protoless `ownedNeighbors` method
- [x] `ProvinceDistances` - split into protoless + `LegacyProvinceDistances`
- [x] `SwornBrotherChooser` - fully protoless (removed `bestChoiceProto`)
**Command Selectors (all use native GameState):**
- [x] `AllianceOfferCommandSelector`
- [x] `AlmsCommandSelector`
- [x] `AttackCommandChooser`
- [x] `ExpandCommandSelector`
- [x] `HeroGiftCommandSelector`
- [x] `ImproveCommandSelector`
- [x] `MarchTowardProvinceCommandChooser` - in AI folder, uses native GameState (callers convert)
- [x] `OrganizeCommandSelector`
- [x] `RansomOfferHelpers`
- [x] `SeekMoreLeadersCommandChooser` - in AI folder, uses native GameState
- [x] `TruceOfferCommandSelector`
- [x] `TrustForDiplomacy`
**Quest Command Selectors (all protoless):**
- [x] `AllianceQuestCommandChooser`
- [x] `AlmsAcrossRealmQuestCommandChooser`
- [x] `AlmsToProvinceQuestCommandChooser`
- [x] `DismissSpecificVassalCommandChooser`
- [x] `GiveToHeroesAcrossRealmQuestCommandChooser`
- [x] `GiveToHeroesInProvinceQuestCommandChooser`
- [x] `ImproveQuestCommandChooser`
- [x] `QuestCommandChooser`
- [x] `TruceCountQuestCommandChooser`
- [x] `TruceWithFactionQuestCommandChooser`
### Fully Protoless
- [x] `AIClientUtils` - has protoless overloads (`takenHeroIdsForMarchTowardFocus`, `mostPowerfulHeroes`)
- [x] `AttackCommandChooser` - uses Scala `GameState` and `BattalionViewC` (Scala view type)
- [x] `BattalionPower` - has `estimatedPower(BattalionViewC)` for recon data with optional stats
- [x] `CommandChoiceHelpers` - fully protoless, uses Scala `GameState` throughout
- [x] `ProvinceGoldSurplusCalculator` - fully protoless (callers use converters)
- [x] `HeroSelector` - fully protoless (removed dead `minimallyFatiguedHeroesProto`)
### AI Layer ✅ COMPLETE
All AI and command chooser code is now fully protoless:
- [x] `AttackDecisionCommandChooser` - uses Scala GameState
- [x] `CommandChooser` - trait uses Scala GameState
- [x] `FulfillQuestsCommandSelector` - uses Scala GameState
- [x] `MidGameAIClient` - uses Scala GameState internally
### Still Using Proto GameState (Boundary Code)
These files use proto GameState because they're at system boundaries:
**View Filters (client projection):**
- `view_filters/GameStateViewFilter` - has Scala overload, uses Scala sub-filters
- `view_filters/ProvinceViewFilter` - has Scala overloads for some methods
- `view_filters/FactionViewFilter` - has Scala overload
- `view_filters/HeroViewFilter` - has Scala overload
- `view_filters/BattalionNameFilter` - has Scala overload
- `view_filters/BattleFilter` - has Scala overload
**Legacy Utilities (to be deprecated):**
- `LegacyProvinceDistances`, `LegacyFactionUtils`, `LegacyHeroUtils`, etc.
- Used by code that still needs proto GameState
**Persistence/Action System:**
- `ActionResultTApplier`, `ActionResultProtoApplier` - apply results to proto state
- `ActionWithResultingState` - caches both proto and Scala state
**Shardok Interface (gRPC boundary):**
- `ShardokInterfaceGrpcClient`, `ShardokInterfaceProxy` - must use proto for C++ communication
## Next Steps
### Phase 1-3: AI Layer ✅ COMPLETE
The entire AI decision-making layer is now protoless.
### Phase 4: View Filters ✅ COMPLETE
The view_filters package migration is complete:
**Completed:**
- [x] `GameStateViewFilter` - added Scala GameState overload (uses Scala sub-filters)
- [x] `ProvinceViewFilter` - already has Scala overloads for server-side views
- [x] `HumanPlayerClientConnectionState` - updated to pass Scala GameState directly
- [x] `HeroViewFilter` - added Scala overload
- [x] `FactionViewFilter` - added Scala overload
- [x] `Visibility` - added Scala overloads
**Still Using Proto:**
- [x] `BattalionNameFilter` - has Scala overload
- [x] `BattleFilter` - has Scala overload
- [ ] `ActionResultFilter` - uses proto internally (boundary code)
**Strategy:**
1. Add Scala GameState overloads to view filter methods
2. Update callers to pass Scala GameState where available
3. Eventually deprecate proto versions
### Phase 5: Legacy Utility Cleanup (IN PROGRESS)
Remove Legacy* utilities by migrating remaining callers:
1. Identify callers of each Legacy* util
2. Update callers to use protoless versions
3. Delete Legacy* files when no longer needed
**Deleted (no production callers):**
- [x] `LegacyProvinceDistances` - deleted (no callers)
- [x] `LegacyBattalionSuitability` - deleted (no callers)
- [x] `LegacyFoodConsumptionUtils` - deleted (no callers)
- [x] `LegacyHandleRiotUtils` - deleted (no callers)
**Refactored to Thin Wrappers (delegating to protoless versions):**
- [x] `LegacyRansomValidity` - already a thin wrapper delegating to `RansomValidity`
- [x] `LegacyRecruitmentOdds` - refactored to delegate to `RecruitmentOdds`
**Parallel Implementations (proto mirrors protoless):**
- [x] `FactionUtils` / `LegacyFactionUtils` - both have matching APIs; LegacyFactionUtils used by boundary code (24+ callers)
- [x] `HeroUtils` / `LegacyHeroUtils` - both have matching APIs; LegacyHeroUtils used by boundary code (10 callers)
- [x] `ProvinceUtils` / `LegacyProvinceUtils` - both have matching APIs; LegacyProvinceUtils used by boundary code (20 callers: availability factories, view filters)
**Parallel Implementations (awaiting migration of callers):**
- [x] `BattalionUtils` / `LegacyBattalionUtils` - both have matching core methods; LegacyBattalionUtils used by boundary code (4 callers)
- [x] `BattalionViewFilter` / `LegacyBattalionViewFilter` - protoless version exists; Legacy used by view filters, action appliers (3 callers)
- [x] `BattalionTypeFinder` / `LegacyBattalionTypeFinder` - protoless version exists; Legacy used by validators (1 caller: RuntimeValidator)
### Recent Caller Migration
**CheckForFulfilledQuestsAction** - migrated to use protoless `BattalionTypeFinder`:
- Changed `battalionTypes` parameter from proto `Vector[BattalionType]` to Scala `Vector[BattalionType]`
- Updated callers (EngineImpl, EndVassalCommandsPhaseAction) to pass Scala types directly
- Eliminated wasteful `BattalionTypeConverter.toProto()` conversions
**ExpandedUnaffiliatedHeroUtils** - added Scala overload:
- New overload takes Scala `GameState` and `UnaffiliatedHeroT` directly
- Added `UnaffiliatedHeroConverter.unaffiliatedHeroTypeToProto()` helper for efficient enum conversion
- Proto overload retained for backward compatibility
**AvailablePleaseRecruitMeCommandFactory** - eliminated wasteful proto conversions:
- Now uses `ExpandedUnaffiliatedHeroUtils.expandedUnaffiliatedHero(ScalaGameState, UnaffiliatedHeroT)` directly
- Removed `GameStateConverter.toProto()` and `UnaffiliatedHeroConverter.toProto()` calls
- Factory is now fully protoless internally (still returns proto types for API boundary)
**ProvinceViewFilter** - added Scala overload with faction filtering:
- New `filteredProvinceView(province: ProvinceT, gs: ScalaGameState, factionId: FactionId)` overload
- Uses protoless `FactionUtils.hasAlliance`, `Visibility.hasFullVisibility`, and `ProvinceUtils.incomingOthers`
- Handles reconned provinces directly from Scala `FactionT.reconnedProvinces` (already Scala type)
- Added helper methods: `fullProvinceInfoScala`, `maybeIncomingAttackersScala`, `unaffiliatedHeroInfoScala`
- Events still converted to proto at the end (ProvinceView.knownEvents uses proto events)
**GameStateViewFilter** - eliminated GameStateConverter.toProto() call:
- Scala overload now fully protoless internally
- Uses the new ProvinceViewFilter Scala overload with faction filtering
- Converts `battalionTypes` and `chronicleEntries` to proto only at output boundary
## Key Files
### Protoless Model Types
- `src/main/scala/net/eagle0/eagle/model/state/game_state/GameState.scala` - native Scala GameState
- `src/main/scala/net/eagle0/eagle/model/state/province/ProvinceView.scala` - province view type
- `src/main/scala/net/eagle0/eagle/model/state/faction/FactionView.scala` - faction view type
- `src/main/scala/net/eagle0/eagle/model/state/hero/HeroView.scala` - hero view type
### Proto Converters
- `src/main/scala/net/eagle0/eagle/model/proto_converters/game_state/` - converts between proto and Scala types
## Notes
- The AI client code (`src/main/scala/net/eagle0/eagle/ai/`) is now fully protoless
- Proto GameState is still needed at boundaries: persistence, gRPC to Shardok
- `PerformUnaffiliatedHeroesAction` and the LLM pipeline use protoless `GameState`
- `GameStateViewFilter` Scala overload is now fully protoless internally (converts to proto only at output)
- `ProvinceViewFilter` has Scala overloads for all three modes: no filtering, faction filtering, and withdrawn-from view
---
## Proto Import Inventory (library/)
**Total: 149 proto imports across 51 files** (as of 2026-01-13)
### Proto Dependencies by BUILD.bazel (unique deps)
#### library/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:action_result_type_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:round_phase_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:action_result_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:game_state_view_scala_proto`
- `//src/main/protobuf/net/eagle0/shardok/api:action_result_view_scala_proto`
- `//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_scala_proto`
- `//src/main/protobuf/net/eagle0/shardok/storage:action_result_scala_proto`
#### library/actions/impl/action/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:action_result_type_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:province_order_type_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:unaffiliated_hero_quest_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`
#### library/actions/impl/common/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`
#### library/actions/llm_prompt_generators/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:event_for_chronicle_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto`
#### library/actions/llm_request_generators/diplomacy_llm_helpers/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:province_scala_proto`
#### library/settings/loaders/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/common:battalion_type_scala_proto`
#### library/util/BUILD.bazel
- `//src/main/protobuf/net/eagle0/common:hostility_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api/command/util:army_stats_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:date_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:province_event_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:round_phase_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:army_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:faction_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:hero_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:province_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:battalion_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:faction_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:game_state_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:hero_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:province_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:stat_with_condition_scala_proto`
#### library/util/command_choice_helpers/BUILD.bazel
- `//src/main/protobuf/net/eagle0/common:hostility_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api/command/util:armed_battalion_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api/command/util:battalion_with_food_cost_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:battalion_type_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:combat_unit_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_scala_proto`
#### library/util/command_choice_helpers/quest_command_selectors/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:improvement_type_scala_proto`
#### library/util/faction_utils/BUILD.bazel
- `//src/main/protobuf/net/eagle0/common:hostility_scala_proto`
#### library/util/quest_creation/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto`
#### library/util/quest_fulfillment/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/internal:unaffiliated_hero_scala_proto`
#### library/util/view_filters/BUILD.bazel
- `//src/main/protobuf/net/eagle0/common:hostility_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:province_event_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:army_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:faction_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:hero_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:province_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:shardok_battle_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:army_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:battalion_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:faction_relationship_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:faction_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:game_state_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:hero_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:incoming_army_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:province_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:shardok_battle_view_scala_proto`
---
### Summary by Directory
| Directory | Files | Imports | Status |
|-----------|-------|---------|--------|
| `actions/availability/` | 0 | 0 | ✅ Clean |
| `actions/impl/action/` | 2 | 3 | Boundary code |
| `actions/impl/common/` | 1 | 2 | Boundary code |
| `actions/llm_prompt_generators/` | 24 | 46 | LLM request types |
| `actions/llm_request_generators/` | 1 | 2 | LLM request types |
| `settings/loaders/` | 1 | 1 | Loader (expected) |
| `util/` | 9 | 20 | Mixed - cleanup candidates |
| `util/command_choice_helpers/` | 17 | 57 | API types |
| `util/view_filters/` | 1 | 1 | View boundary |
| Root (`library/`) | 3 | 5 | Boundary code |
---
### Detailed Inventory
#### Root library/ (5 imports, 3 files)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `ActionResultFilter.scala` | 3 | `common.action_result_notification_details.Notification` | Action result filtering |
| `ActionResultFilter.scala` | 4 | `common.action_result_type.ActionResultType` | |
| `ActionResultFilter.scala` | 5 | `common.action_result_type.ActionResultType.*` | |
| `Engine.scala` | 6 | `internal.action_result.ActionResult` | Trait interface |
| `EngineImpl.scala` | 8 | `internal.action_result.ActionResult` | Returns proto for persistence |
#### actions/impl/action/ (3 imports, 2 files)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `ChronicleEventGenerator.scala` | 3 | `common.action_result_type.ActionResultType.FACTION_DESTROYED` | Single enum value |
| `PerformVassalCommandsPhaseAction.scala` | 5 | `api.available_command.{AvailableCommand, RestAvailableCommand}` | Commands from factory |
| `PerformVassalDefenseDecisionsAction.scala` | 4 | `api.available_command.AvailableCommand` | Commands from factory |
#### actions/impl/common/ (2 imports, 1 file)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `ActionWithResultingState.scala` | 3 | `internal.action_result.ActionResult` | Caches both proto and Scala |
| `ActionWithResultingState.scala` | 4 | `internal.game_state.GameState` | |
#### actions/llm_prompt_generators/ (46 imports, 24 files)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `AllianceOfferMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.AllianceOfferMessage` | LLM request type |
| `AllianceResolutionMessagePromptGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `AllianceResolutionMessagePromptGenerator.scala` | 5 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `AllianceResolutionMessagePromptGenerator.scala` | 13 | `internal.generated_text_request.AllianceOfferResolutionMessage` | |
| `BattalionDescriptions.scala` | 3 | `common.battalion_type.BattalionTypeId` | |
| `BattalionDescriptions.scala` | 4 | `internal.battalion.Battalion` | |
| `BreakAllianceMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.BreakAllianceMessage` | |
| `BreakAllianceResolutionMessagePromptGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `BreakAllianceResolutionMessagePromptGenerator.scala` | 5 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `BreakAllianceResolutionMessagePromptGenerator.scala` | 13 | `internal.generated_text_request.BreakAllianceResolutionMessage` | |
| `CapturedHeroMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.{...}` | |
| `ChronicleEventTextGenerator.scala` | 4 | `internal.event_for_chronicle.{...}` | |
| `ChronicleEventTextGenerator.scala` | 31 | `internal.event_for_chronicle.ShatteredArmyEvent.Reason.{...}` | |
| `ChronicleUpdatePromptGenerator.scala` | 4 | `common.date.Date` | |
| `ChronicleUpdatePromptGenerator.scala` | 5 | `internal.event_for_chronicle.EventForChronicle` | |
| `ChronicleUpdatePromptGenerator.scala` | 6 | `internal.generated_text_request.ChronicleUpdateMessage` | |
| `DivineMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.DivineMessage` | |
| `ExileVassalPromptGenerator.scala` | 4 | `internal.generated_text_request.ExileVassalMessage` | |
| `GeneratorUtilities.scala` | 13 | `common.date.Date` | |
| `HandleCapturedHeroPleaPromptGenerator.scala` | 4 | `internal.generated_text_request.HandleCapturedHeroPlea` | |
| `HeroBackstoryUpdatePromptGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `HeroBackstoryUpdatePromptGenerator.scala` | 9 | `internal.event_for_hero_backstory.{...}` | |
| `HeroBackstoryUpdatePromptGenerator.scala` | 37 | `internal.event_for_hero_backstory.FoughtInBattleBackstoryEvent.UnitStatus` | |
| `HeroBackstoryUpdatePromptGenerator.scala` | 38 | `internal.event_for_hero_backstory.FoughtInBattleBackstoryEvent.UnitStatus.{...}` | |
| `HeroBackstoryUpdatePromptGenerator.scala` | 47 | `internal.generated_text_request.HeroBackstoryUpdateRequest` | |
| `HeroDeparturePromptGenerator.scala` | 4 | `internal.generated_text_request.HeroDepartureMessage` | |
| `HeroInitialBackstoryPromptGenerator.scala` | 4 | `internal.generated_text_request.HeroInitialBackstoryRequest` | |
| `InvitationMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.InvitationMessage` | |
| `InvitationResolutionMessagePromptGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `InvitationResolutionMessagePromptGenerator.scala` | 5 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `InvitationResolutionMessagePromptGenerator.scala` | 13 | `internal.generated_text_request.InvitationResolutionMessage` | |
| `NewFactionHeadPromptGenerator.scala` | 4 | `internal.generated_text_request.NewFactionHeadMessage` | |
| `PleaseRecruitMePromptGenerator.scala` | 4 | `internal.generated_text_request.PleaseRecruitMeMessage` | |
| `PrisonerExecutedPromptGenerator.scala` | 4 | `internal.generated_text_request.PrisonerExecutedMessage` | |
| `PrisonerExiledPromptGenerator.scala` | 4 | `internal.generated_text_request.PrisonerExiledMessage` | |
| `PrisonerReleasedPromptGenerator.scala` | 4 | `internal.generated_text_request.PrisonerReleasedMessage` | |
| `PrisonerReturnedPromptGenerator.scala` | 4 | `internal.generated_text_request.PrisonerReturnedMessage` | |
| `ProfessionGainedPromptGenerator.scala` | 4 | `internal.generated_text_request.ProfessionGainedMessage` | |
| `QuestEndedGeneratorUtilities.scala` | 5 | `common.battalion_type.BattalionTypeId` | |
| `QuestEndedGeneratorUtilities.scala` | 6 | `common.unaffiliated_hero_quest.{...}` | |
| `QuestEndedGeneratorUtilities.scala` | 29 | `common.unaffiliated_hero_quest.QuestDetails.Empty` | |
| `QuestFailedPromptGenerator.scala` | 4 | `internal.generated_text_request.QuestFailedMessage` | |
| `QuestFulfilledPromptGenerator.scala` | 4 | `internal.generated_text_request.QuestFulfilledMessage` | |
| `RansomOfferMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.RansomOfferMessage` | |
| `RansomResolutionMessagePromptGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `RansomResolutionMessagePromptGenerator.scala` | 5 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `RansomResolutionMessagePromptGenerator.scala` | 13 | `internal.generated_text_request.RansomResolutionMessage` | |
| `RecruitmentRefusedPromptGenerator.scala` | 4 | `internal.generated_text_request.RecruitmentRefusedMessage` | |
| `SuppressBeastsPromptGenerator.scala` | 4 | `internal.generated_text_request.{...}` | |
| `SwearBrotherhoodPromptGenerator.scala` | 3 | `common.gender.Gender.{...}` | |
| `SwearBrotherhoodPromptGenerator.scala` | 4 | `internal.generated_text_request.SwearBrotherhoodMessage` | |
| `TruceOfferMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.TruceOfferMessage` | |
| `TruceResolutionMessagePromptGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `TruceResolutionMessagePromptGenerator.scala` | 5 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `TruceResolutionMessagePromptGenerator.scala` | 13 | `internal.generated_text_request.TruceResolutionMessage` | |
#### actions/llm_request_generators/ (2 imports, 1 file)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `DiplomacyResolutionLlmRequestGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `DiplomacyResolutionLlmRequestGenerator.scala` | 5 | `internal.generated_text_request.{...}` | |
#### settings/loaders/ (1 import, 1 file)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `BattalionTypeLoader.scala` | 8 | `common.battalion_type.{BattalionType, BattalionTypeId}` | Loads from file, converts immediately |
#### util/ (20 imports, 9 files)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `ArmyUtils.scala` | 3 | `internal.army.{Army, HostileArmyGroup, MovingArmy}` | Has Scala overloads |
| `ArmyUtils.scala` | 4 | `internal.game_state.GameState` | |
| `CommandSelection.scala` | 4 | `api.available_command.AvailableCommand` | Wraps proto command |
| `CommandSelection.scala` | 5 | `api.selected_command.SelectedCommand` | |
| `DateProtoUtils.scala` | 5 | `common.date.Date` | Conversion utility |
| `GameStateViewDiffer.scala` | 3 | `common.round_phase.NewRoundPhase` | View diff for client |
| `IDable.scala` | 4 | `internal.battalion.Battalion` | Test helper only |
| `IDable.scala` | 5 | `internal.faction.Faction` | |
| `IDable.scala` | 6 | `internal.hero.Hero` | |
| `IDable.scala` | 7 | `internal.province.Province` | |
| `IncomingArmyUtils.scala` | 4 | `api.command.util.army_stats.ArmyStats` | Returns proto type |
| `IncomingArmyUtils.scala` | 5 | `internal.army.{Army, MovingArmy}` | Has Scala overloads |
| `IncomingArmyUtils.scala` | 6 | `internal.game_state.GameState` | |
| `IncomingArmyUtils.scala` | 7 | `internal.province.Province` | |
| `MapGenerator.scala` | 6 | `internal.province.{Neighbor, Province}` | Map generation |
| `ProvinceEventUtils.scala` | 3 | `common.beast_info.BeastInfo` | |
| `ProvinceEventUtils.scala` | 4 | `common.province_event.*` | |
#### util/command_choice_helpers/ (57 imports, 17 files)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `AllianceOfferCommandSelector.scala` | 4 | `api.available_command.{AvailableCommand, DiplomacyAvailableCommand}` | |
| `AllianceOfferCommandSelector.scala` | 5 | `api.command.util.diplomacy_option.{AllianceOption, DiplomacyOption}` | |
| `AllianceOfferCommandSelector.scala` | 6 | `api.selected_command.DiplomacySelectedCommand` | |
| `AlmsCommandSelector.scala` | 4 | `api.available_command.{AlmsAvailableCommand, AvailableCommand}` | |
| `AlmsCommandSelector.scala` | 5 | `api.selected_command.AlmsSelectedCommand` | |
| `AttackCommandChooser.scala` | 4 | `api.available_command.*` | |
| `AttackCommandChooser.scala` | 5 | `api.selected_command.*` | |
| `AttackCommandChooser.scala` | 6 | `common.combat_unit.CombatUnit` | |
| `AttackDecisionCommandChooser.scala` | 5 | `api.available_command.{AttackDecisionAvailableCommand, AvailableCommand}` | |
| `AttackDecisionCommandChooser.scala` | 6 | `api.command.util.attack_decision_type.{...}` | |
| `AttackDecisionCommandChooser.scala` | 12 | `api.selected_command.AttackDecisionSelectedCommand` | |
| `AttackDecisionCommandChooser.scala` | 13 | `common.tribute_amount.TributeAmount` | |
| `AvailableCommandSelector.scala` | 6 | `api.available_command.AvailableCommand` | |
| `CombatUnitSelector.scala` | 5 | `common.combat_unit.CombatUnit` | |
| `CommandChoiceHelpers.scala` | 5 | `api.available_command.*` | |
| `CommandChoiceHelpers.scala` | 6 | `api.command.util.armed_battalion.ArmedBattalion` | |
| `CommandChoiceHelpers.scala` | 7 | `api.command.util.attack_decision_type.{AdvanceDecision, WithdrawDecision}` | |
| `CommandChoiceHelpers.scala` | 8 | `api.command.util.captured_hero_option.CapturedHeroOption.{...}` | |
| `CommandChoiceHelpers.scala` | 13 | `api.command.util.diplomacy_option.RansomOfferOption` | |
| `CommandChoiceHelpers.scala` | 14 | `api.selected_command.*` | |
| `CommandChoiceHelpers.scala` | 15 | `api.selected_command.OrganizeTroopsSelectedCommand.{ChangedBattalion, NewBattalion}` | |
| `CommandChoiceHelpers.scala` | 16 | `common.battalion_type.BattalionTypeId` | |
| `CommandChoiceHelpers.scala` | 17 | `common.combat_unit.CombatUnit` | |
| `CommandChoiceHelpers.scala` | 18 | `common.improvement_type.ImprovementType.INFRASTRUCTURE` | |
| `CommandChoiceHelpers.scala` | 19 | `common.tribute_amount.TributeAmount` | |
| `CommandChooser.scala` | 6 | `api.available_command.AvailableCommand` | |
| `ExileVassalCommandSelector.scala` | 4 | `api.available_command.{AvailableCommand, ExileVassalAvailableCommand}` | |
| `ExileVassalCommandSelector.scala` | 5 | `api.selected_command.ExileVassalSelectedCommand` | |
| `ExpandCommandSelector.scala` | 7 | `api.available_command.{AvailableCommand, MarchAvailableCommand, MarchCommandFromOneProvince}` | |
| `ExpandCommandSelector.scala` | 8 | `api.selected_command.MarchSelectedCommand` | |
| `ExpandCommandSelector.scala` | 9 | `common.combat_unit.CombatUnit` | |
| `FulfillQuestsCommandSelector.scala` | 4 | `api.available_command.AvailableCommand` | |
| `HeroGiftCommandSelector.scala` | 4 | `api.available_command.{AvailableCommand, HeroGiftAvailableCommand}` | |
| `HeroGiftCommandSelector.scala` | 5 | `api.selected_command.HeroGiftSelectedCommand` | |
| `ImproveCommandSelector.scala` | 4 | `api.available_command.{AvailableCommand, ImproveAvailableCommand}` | |
| `ImproveCommandSelector.scala` | 5 | `api.selected_command.ImproveSelectedCommand` | |
| `ImproveCommandSelector.scala` | 6 | `common.improvement_type.ImprovementType` | |
| `ImproveCommandSelector.scala` | 7 | `common.improvement_type.ImprovementType.DEVASTATION` | |
| `OrganizeCommandSelector.scala` | 5 | `api.available_command.{AvailableCommand, OrganizeTroopsAvailableCommand}` | |
| `OrganizeCommandSelector.scala` | 6 | `api.selected_command.OrganizeTroopsSelectedCommand` | |
| `OrganizeCommandSelector.scala` | 7 | `api.selected_command.OrganizeTroopsSelectedCommand.{ChangedBattalion, NewBattalion}` | |
| `OrganizeCommandSelector.scala` | 8 | `common.battalion_type.BattalionTypeId` | |
| `RansomOfferHelpers.scala` | 3 | `common.diplomacy_offer.RansomOfferDetails` | |
| `RansomOfferHelpers.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `RansomOfferHelpers.scala` | 5 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `SwearBrotherhoodCommandSelector.scala` | 4 | `api.available_command.{AvailableCommand, SwearBrotherhoodAvailableCommand}` | |
| `SwearBrotherhoodCommandSelector.scala` | 5 | `api.selected_command.SwearBrotherhoodSelectedCommand` | |
| `TruceOfferCommandSelector.scala` | 4 | `api.available_command.{AvailableCommand, DiplomacyAvailableCommand}` | |
| `TruceOfferCommandSelector.scala` | 5 | `api.command.util.diplomacy_option.{DiplomacyOption, TruceOption}` | |
| `TruceOfferCommandSelector.scala` | 6 | `api.selected_command.DiplomacySelectedCommand` | |
#### util/command_choice_helpers/quest_command_selectors/ (12 imports, 10 files)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `AllianceQuestCommandChooser.scala` | 4 | `api.available_command.{AvailableCommand, DiplomacyAvailableCommand}` | |
| `AllianceQuestCommandChooser.scala` | 5 | `api.command.util.diplomacy_option.AllianceOption` | |
| `AlmsAcrossRealmQuestCommandChooser.scala` | 2 | `api.available_command.AvailableCommand` | |
| `AlmsToProvinceQuestCommandChooser.scala` | 3 | `api.available_command.AvailableCommand` | |
| `DismissSpecificVassalCommandChooser.scala` | 5 | `api.available_command.AvailableCommand` | |
| `GiveToHeroesAcrossRealmQuestCommandChooser.scala` | 2 | `api.available_command.AvailableCommand` | |
| `GiveToHeroesInProvinceQuestCommandChooser.scala` | 4 | `api.available_command.AvailableCommand` | |
| `ImproveQuestCommandChooser.scala` | 3 | `api.available_command.AvailableCommand` | |
| `ImproveQuestCommandChooser.scala` | 4 | `common.improvement_type.ImprovementType.{...}` | |
| `QuestCommandChooser.scala` | 4 | `api.available_command.AvailableCommand` | |
| `TruceCountQuestCommandChooser.scala` | 4 | `api.available_command.AvailableCommand` | |
| `TruceWithFactionQuestCommandChooser.scala` | 3 | `api.available_command.AvailableCommand` | |
#### util/view_filters/ (1 import, 1 file)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `ProvinceViewFilter.scala` | 3 | `common.province_event.ProvinceEvent` | For knownEvents field |
#### util/province/ (1 import, 1 file)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `ProvinceUtils.scala` | 4 | `common.battalion_type.BattalionType` | Single proto reference |
---
### Cleanup Candidates (Priority Order)
1. **Delete proto overloads where Scala overloads exist and proto unused**
- `IncomingArmyUtils.scala` - verify all main callers use Scala overloads
- `ArmyUtils.scala` - verify all main callers use Scala overloads
2. **Move test-only utilities to test code**
- `IDable.scala` - only used by tests (except StartGameActionResultUtils)
3. **Create Scala versions of common enums**
- `DiplomacyOfferStatus` → already done in availability package
- `ImprovementType` → used by ImproveCommandSelector, CommandChoiceHelpers
- `BattalionTypeId` → used by multiple command selectors
4. **Large refactoring (requires Scala AvailableCommand/SelectedCommand)**
- `command_choice_helpers/` package (57 imports) - uses proto API command types
- Would need Scala versions of AvailableCommand and SelectedCommand hierarchies
+21 -213
View File
@@ -1,27 +1,26 @@
module(name = "net_eagle0")
# Version constants
SCALA_VERSION = "3.7.4"
SCALA_VERSION = "3.7.2"
NETTY_VERSION = "4.1.127.Final"
NETTY_VERSION = "4.1.110.Final"
SCALAPB_VERSION = "1.0.0-alpha.1"
AWS_SDK_VERSION = "2.41.18"
AWS_SDK_VERSION = "2.28.1"
#
# Core Build Tools
#
bazel_dep(name = "bazel_skylib", version = "1.9.0")
bazel_dep(name = "platforms", version = "1.0.0")
bazel_dep(name = "rules_pkg", version = "1.2.0")
#
# Language Support - Scala
#
bazel_dep(name = "rules_scala", version = "7.2.1")
bazel_dep(name = "rules_scala", version = "7.1.1")
scala_config = use_extension(
"@rules_scala//scala/extensions:config.bzl",
@@ -48,14 +47,14 @@ llvm = use_extension("@toolchains_llvm//toolchain/extensions:llvm.bzl", "llvm")
# Native toolchain (macOS -> macOS, Linux -> Linux)
llvm.toolchain(
name = "llvm_toolchain",
llvm_version = "20.1.4",
llvm_version = "20.1.2",
)
# Cross-compilation toolchain (macOS -> Linux x86_64)
# Uses the same LLVM distribution but with a Linux sysroot
llvm.toolchain(
name = "llvm_toolchain_linux",
llvm_version = "20.1.4",
llvm_version = "20.1.2",
)
# Linux x86_64 sysroot for cross-compilation
@@ -68,7 +67,7 @@ llvm.sysroot(
# Cross-compilation toolchain (macOS -> Linux ARM64)
llvm.toolchain(
name = "llvm_toolchain_linux_arm64",
llvm_version = "20.1.4",
llvm_version = "20.1.2",
)
# Linux ARM64 sysroot for cross-compilation
@@ -108,7 +107,6 @@ bazel_dep(name = "gazelle", version = "0.47.0", repo_name = "bazel_gazelle")
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")
@@ -120,86 +118,26 @@ use_repo(
"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 = "apple_support", version = "1.21.1", repo_name = "build_bazel_apple_support")
bazel_dep(name = "rules_apple", version = "4.3.3", repo_name = "build_bazel_rules_apple")
# Register Apple CC toolchain for Objective-C compilation
apple_cc_configure = use_extension(
"@build_bazel_apple_support//crosstool:setup.bzl",
"apple_cc_configure_extension",
)
use_repo(apple_cc_configure, "local_config_apple_cc", "local_config_apple_cc_toolchains")
bazel_dep(name = "rules_swift", version = "2.4.0", repo_name = "build_bazel_rules_swift")
#
# 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", version = "29.2", repo_name = "com_google_protobuf")
bazel_dep(name = "grpc", version = "1.71.0")
bazel_dep(name = "grpc-java", version = "1.71.0")
bazel_dep(name = "flatbuffers", version = "25.9.23")
#
# Testing
@@ -212,29 +150,16 @@ bazel_dep(name = "googletest", version = "1.17.0")
#
bazel_dep(name = "rules_oci", version = "2.2.7")
bazel_dep(name = "aspect_bazel_lib", version = "2.22.5")
bazel_dep(name = "aspect_bazel_lib", version = "2.22.4")
oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
# Base image for Eagle (Java 25 JRE - smaller runtime, no dev tools needed)
# 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']"
# Base image for Eagle (Java 17 JDK - includes jcmd for JFR dumps)
oci.pull(
name = "eclipse_temurin_25_jre",
digest = "sha256:98236ffdfb61cff3fc4f8e40e5b80460a5a4c35a0f56a5b4182a926844a7db49",
name = "eclipse_temurin_17",
image = "docker.io/library/eclipse-temurin",
platforms = ["linux/amd64"],
tag = "25-jre",
)
# Base image for JFR sidecar (Java 25 JDK - includes jcmd for JFR dumps)
oci.pull(
name = "eclipse_temurin_25_jdk",
digest = "sha256:bc2d562355813350f47bc472b9721b0b84761866671ad95d9a9f1151fa69da6e",
image = "docker.io/library/eclipse-temurin",
platforms = ["linux/amd64"],
tag = "25-jdk",
tag = "17-jdk",
)
# Base image for Shardok (Ubuntu 24.04 for C++ runtime)
@@ -255,14 +180,13 @@ oci.pull(
platforms = ["linux/amd64"],
tag = "3.21",
)
use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_25_jdk", "eclipse_temurin_25_jdk_linux_amd64", "eclipse_temurin_25_jre", "eclipse_temurin_25_jre_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64", "ubuntu_24_04_linux_arm64_v8")
use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_17", "eclipse_temurin_17_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64", "ubuntu_24_04_linux_arm64_v8")
#
# Java/Scala Dependencies
#
bazel_dep(name = "rules_java", version = "9.3.0")
bazel_dep(name = "rules_jvm_external", version = "6.10")
bazel_dep(name = "rules_jvm_external", version = "6.9")
maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")
maven.install(
@@ -295,9 +219,6 @@ maven.install(
# Testing
"org.scalamock:scalamock_3:7.4.1",
# Developer tools
"org.scalameta:scalafmt-cli_2.13:3.9.9",
# AWS SDK
"software.amazon.awssdk:s3-transfer-manager:%s" % AWS_SDK_VERSION,
"software.amazon.awssdk:s3:%s" % AWS_SDK_VERSION,
@@ -327,13 +248,7 @@ maven.install(
"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",
# Postgres for production history benchmarking and migration work
"org.postgresql:postgresql:42.7.4",
"io.sentry:sentry:7.19.0",
],
duplicate_version_warning = "error",
fail_if_repin_required = True,
@@ -342,36 +257,6 @@ maven.install(
"https://repo1.maven.org/maven2",
],
)
# json4s' Scala 3 reflection path can load scala.quoted.staging at runtime, but
# its Maven metadata does not bring the staging jar transitively.
maven.artifact(
artifact = "scala3-staging_3",
exclusions = ["org.scala-lang:scala3-compiler_3"],
group = "org.scala-lang",
version = "3.3.6",
)
# 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")
#
@@ -388,14 +273,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,
@@ -415,17 +293,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)
@@ -451,60 +318,6 @@ http_file(
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
#
@@ -514,11 +327,6 @@ 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",
+3830 -2964
View File
File diff suppressed because one or more lines are too long
+9 -69
View File
@@ -1,19 +1,5 @@
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load", "oci_push")
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
load("//ci:jar_split.bzl", "jar_split")
#
# 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.)
@@ -51,37 +37,11 @@ pkg_tar(
# Push: bazel run //ci:eagle_server_push
#
# Split the Eagle server runtime classpath into a stable third-party layer
# (Scala stdlib, gRPC, Netty, ScalaPB, AWS SDK, ...) and a small first-party
# layer that changes every commit. Most pushes then only re-upload the small
# app layer instead of the whole ~150-300MB fat JAR.
jar_split(
name = "eagle_server_jars",
binary = "//src/main/scala/net/eagle0/eagle:eagle_server",
)
filegroup(
name = "eagle_server_deps_jars",
srcs = [":eagle_server_jars"],
output_group = "deps",
)
filegroup(
name = "eagle_server_app_jars",
srcs = [":eagle_server_jars"],
output_group = "app",
)
# Package the deploy JAR
pkg_tar(
name = "eagle_server_deps_layer",
srcs = [":eagle_server_deps_jars"],
package_dir = "/app/lib/deps",
)
pkg_tar(
name = "eagle_server_app_layer",
srcs = [":eagle_server_app_jars"],
package_dir = "/app/lib/app",
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
@@ -100,7 +60,7 @@ pkg_tar(
oci_image(
name = "eagle_server_image",
base = "@eclipse_temurin_25_jre_linux_amd64",
base = "@eclipse_temurin_17_linux_amd64",
entrypoint = [
"java",
"-Xmx2g",
@@ -109,11 +69,8 @@ oci_image(
"-XX:+UnlockDiagnosticVMOptions",
"-XX:+DebugNonSafepoints", # Required for JFR to see through inlined methods
"-XX:FlightRecorderOptions=stackdepth=256",
# Classpath glob is expanded by the JVM itself (exec-form, no shell).
# Deps dir first keeps third-party precedence; app last shadows nothing.
"-cp",
"/app/lib/deps/*:/app/lib/app/*",
"net.eagle0.eagle.Main",
"-jar",
"/app/eagle_server_deploy.jar",
],
env = {
"JAVA_OPTS": "-Xmx2g -XX:+UseG1GC",
@@ -121,8 +78,7 @@ oci_image(
exposed_ports = ["40032/tcp"],
tars = [
":busybox_layer",
":eagle_server_deps_layer",
":eagle_server_app_layer",
":eagle_server_jar_layer",
":eagle_resources_layer",
],
workdir = "/app",
@@ -274,13 +230,6 @@ pkg_tar(
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",
@@ -289,7 +238,6 @@ oci_image(
tars = [
":busybox_layer",
":admin_binary_layer",
":admin_maps_layer",
],
workdir = "/app",
)
@@ -327,7 +275,7 @@ pkg_tar(
oci_image(
name = "jfr_sidecar_image",
# Use JDK base image - we need jcmd to dump JFR recordings
base = "@eclipse_temurin_25_jdk_linux_amd64",
base = "@eclipse_temurin_17_linux_amd64",
entrypoint = ["/app/jfr_server_linux_amd64"],
exposed_ports = ["8081/tcp"],
tars = [
@@ -369,13 +317,6 @@ pkg_tar(
package_dir = "/app",
)
# Package the attributions.json for the credits page
pkg_tar(
name = "auth_attributions_layer",
srcs = ["//src/main/resources/net/eagle0:attributions"],
package_dir = "/app",
)
oci_image(
name = "auth_server_image",
base = "@alpine_linux_linux_amd64",
@@ -387,7 +328,6 @@ oci_image(
tars = [
":busybox_layer",
":auth_binary_layer",
":auth_attributions_layer",
],
workdir = "/app",
)
+28
View File
@@ -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/
-155
View File
@@ -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
+2 -40
View File
@@ -2,8 +2,7 @@
set -euxo pipefail
# Read Unity version from project file
UNITY_VERSION=$(grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //')
. ./ci/unity_version.sh
WORKSPACE=$(pwd)
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
@@ -16,47 +15,10 @@ 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" \
-buildOSXUniversalPlayer "$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
if [ ! -f "$LOG_PATH" ]; then
echo ""
echo "ERROR: Unity exited successfully but did not write an editor log at $LOG_PATH"
exit 1
fi
# Fail the build if any prefab references are broken — this produces a player
# that launches but has null Inspector fields, which is hard to debug.
if grep -q "Missing Prefab" "$LOG_PATH"; then
echo ""
echo "ERROR: Build has missing prefab references (likely stale Library cache):"
grep "Missing Prefab" "$LOG_PATH"
exit 1
fi
if grep -q "Build asset version error" "$LOG_PATH"; then
echo ""
echo "ERROR: Build has asset version mismatches (likely stale Library cache):"
grep "Build asset version error" "$LOG_PATH" | head -5
exit 1
fi
+5 -3
View File
@@ -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
-74
View File
@@ -1,74 +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
# Fail the build if any prefab references are broken — this produces a player
# that launches but has null Inspector fields, which is hard to debug.
if grep -q "Missing Prefab" "$LOG_PATH"; then
echo ""
echo "ERROR: Build has missing prefab references (likely stale Library cache):"
grep "Missing Prefab" "$LOG_PATH"
exit 1
fi
if grep -q "Build asset version error" "$LOG_PATH"; then
echo ""
echo "ERROR: Build has asset version mismatches (likely stale Library cache):"
grep "Build asset version error" "$LOG_PATH" | head -5
exit 1
fi
echo "iOS Unity build complete"
echo "Xcode project generated at: $BUILD_PATH"
ls -la "$BUILD_PATH"
+3 -6
View File
@@ -2,21 +2,18 @@
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
/bin/echo "build Mac plugin"
./scripts/build_mac_plugin.sh
git log -3
/bin/echo "build Mac"
LOG_PATH="${BUILD_BASE}/editor_mac.log"
LOG_PATH="/tmp/eagle0/editor_mac.log"
BUILD_DIR=$1
./ci/github_actions/build_mac.sh "$BUILD_DIR" "$LOG_PATH"
+2 -40
View File
@@ -2,8 +2,7 @@
set -euxo pipefail
# Read Unity version from project file
UNITY_VERSION=$(grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //')
. ./ci/unity_version.sh
WORKSPACE=`pwd`
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
@@ -16,47 +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
if [ ! -f "$LOG_PATH" ]; then
echo ""
echo "ERROR: Unity exited successfully but did not write an editor log at $LOG_PATH"
exit 1
fi
# Fail the build if any prefab references are broken — this produces a player
# that launches but has null Inspector fields, which is hard to debug.
if grep -q "Missing Prefab" "$LOG_PATH"; then
echo ""
echo "ERROR: Build has missing prefab references (likely stale Library cache):"
grep "Missing Prefab" "$LOG_PATH"
exit 1
fi
if grep -q "Build asset version error" "$LOG_PATH"; then
echo ""
echo "ERROR: Build has asset version mismatches (likely stale Library cache):"
grep "Build asset version error" "$LOG_PATH" | head -5
exit 1
fi
@@ -1,82 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH="${COMMON_PATHS}:${PATH}"
write_developer_dir_override() {
if [ "$(uname -s)" != "Darwin" ]; then
return
fi
local developer_dir=""
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
developer_dir="/Applications/Xcode.app/Contents/Developer"
elif [ -d "/Library/Developer/CommandLineTools" ]; then
developer_dir="/Library/Developer/CommandLineTools"
else
developer_dir="$(xcode-select -p 2>/dev/null || true)"
fi
if [ -z "$developer_dir" ] || [ ! -d "$developer_dir" ]; then
echo "ERROR: no usable Apple developer directory found"
echo "Install Command Line Tools with: xcode-select --install"
echo "Full Xcode is only required for workflows that build/sign Apple apps."
exit 1
fi
export DEVELOPER_DIR="$developer_dir"
if [ -f .bazelrc.local ] && [ -z "${CI:-}" ]; then
echo "Keeping existing .bazelrc.local outside CI"
else
{
echo "# Generated by ci/github_actions/ensure_bazel_installed.sh; do not edit."
printf 'common:macos --repo_env=DEVELOPER_DIR=%s\n' "$developer_dir"
} > .bazelrc.local
fi
if [ -n "${GITHUB_ENV:-}" ]; then
echo "DEVELOPER_DIR=$developer_dir" >> "$GITHUB_ENV"
fi
echo "Using Apple developer directory at $developer_dir"
}
write_developer_dir_override
if [ -n "${GITHUB_PATH:-}" ]; then
for path in /opt/homebrew/bin /usr/local/bin; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
fi
done
fi
if command -v bazel >/dev/null 2>&1; then
echo "Using bazel at $(command -v bazel)"
bazel --version
exit 0
fi
if ! command -v bazelisk >/dev/null 2>&1; then
if ! command -v brew >/dev/null 2>&1; then
echo "ERROR: neither bazel nor bazelisk is on PATH, and Homebrew is unavailable"
exit 1
fi
echo "Installing bazelisk with Homebrew"
brew install bazelisk
fi
BAZEL_BIN_DIR="${RUNNER_TEMP:-/tmp}/bazel-bin"
mkdir -p "$BAZEL_BIN_DIR"
ln -sf "$(command -v bazelisk)" "$BAZEL_BIN_DIR/bazel"
if [ -n "${GITHUB_PATH:-}" ]; then
echo "$BAZEL_BIN_DIR" >> "$GITHUB_PATH"
fi
export PATH="$BAZEL_BIN_DIR:$PATH"
echo "Using bazelisk as bazel at $BAZEL_BIN_DIR/bazel"
bazel --version
-233
View File
@@ -1,233 +0,0 @@
#!/usr/bin/env bash
# Ensures the required Unity version is installed via Unity Hub.
# Usage: ./ensure_unity_installed.sh [PLATFORM]
#
# PLATFORM can be: mac, windows, ios, or all (default: all)
#
# This script:
# 1. Reads the required version from ProjectSettings/ProjectVersion.txt
# 2. Checks if it's already installed
# 3. If not, attempts to install it via Unity Hub CLI with appropriate modules
#
# Note: Unity Hub CLI installation may require:
# - Unity Hub to be installed
# - User to be logged in to Unity Hub (for some versions)
# - Appropriate licenses
set -euo pipefail
# Read Unity version directly from the project file (maintained by Unity itself)
PROJECT_VERSION_FILE="src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
UNITY_VERSION=$(grep "m_EditorVersion:" "$PROJECT_VERSION_FILE" | head -1 | sed 's/m_EditorVersion: //')
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
UNITY_HUB_CLI="/Applications/Unity Hub.app/Contents/MacOS/Unity Hub"
LOCK_FILE="/tmp/unity_install.lock"
LOCK_TIMEOUT=1800 # 30 minutes max wait for another installation
PLATFORM="${1:-all}"
echo "Required Unity version: ${UNITY_VERSION}"
echo "Platform: ${PLATFORM}"
# Check if Unity is installed and has required modules
check_modules_installed() {
local unity_path="${UNITY_INSTALL_PATH}/${UNITY_VERSION}"
if [ ! -d "$unity_path" ]; then
return 1
fi
case "${PLATFORM}" in
ios)
# iOS module installs to PlaybackEngines/iOSSupport
if [ ! -d "${unity_path}/PlaybackEngines/iOSSupport" ]; then
echo "✗ iOS module not installed for Unity ${UNITY_VERSION}"
return 1
fi
;;
mac)
# Mac IL2CPP module
if [ ! -d "${unity_path}/PlaybackEngines/MacStandaloneSupport/Variations/macos_development_il2cpp" ] && \
[ ! -d "${unity_path}/PlaybackEngines/MacStandaloneSupport" ]; then
echo "✗ Mac IL2CPP module not installed for Unity ${UNITY_VERSION}"
return 1
fi
;;
windows)
# Windows mono module
if [ ! -d "${unity_path}/PlaybackEngines/WindowsStandaloneSupport" ]; then
echo "✗ Windows module not installed for Unity ${UNITY_VERSION}"
return 1
fi
;;
all)
# Check all required modules
local missing=0
if [ ! -d "${unity_path}/PlaybackEngines/iOSSupport" ]; then
echo "✗ iOS module missing"
missing=1
fi
if [ ! -d "${unity_path}/PlaybackEngines/MacStandaloneSupport" ]; then
echo "✗ Mac module missing"
missing=1
fi
if [ ! -d "${unity_path}/PlaybackEngines/WindowsStandaloneSupport" ]; then
echo "✗ Windows module missing"
missing=1
fi
if [ $missing -eq 1 ]; then
return 1
fi
;;
esac
return 0
}
# Check if already installed with required modules
if check_modules_installed; then
echo "✓ Unity ${UNITY_VERSION} is already installed with ${PLATFORM} support"
exit 0
fi
if [ ! -d "${UNITY_INSTALL_PATH}/${UNITY_VERSION}" ]; then
echo "✗ Unity ${UNITY_VERSION} not found at ${UNITY_INSTALL_PATH}/${UNITY_VERSION}"
fi
# Check if Unity Hub CLI is available
if [ ! -f "${UNITY_HUB_CLI}" ]; then
echo ""
echo "Unity Hub CLI not found at ${UNITY_HUB_CLI}"
echo ""
echo "To install Unity ${UNITY_VERSION} manually:"
echo " 1. Open Unity Hub"
echo " 2. Go to Installs -> Install Editor"
echo " 3. Select version ${UNITY_VERSION}"
echo " 4. Add modules based on platform: mac-il2cpp, windows-mono, ios"
exit 1
fi
# Acquire lock to prevent concurrent installations
acquire_lock() {
local waited=0
while ! mkdir "${LOCK_FILE}" 2>/dev/null; do
if [ $waited -ge $LOCK_TIMEOUT ]; then
echo "ERROR: Timed out waiting for Unity installation lock after ${LOCK_TIMEOUT}s"
echo "Another installation may be stuck. Remove ${LOCK_FILE} manually if needed."
exit 1
fi
echo "Another Unity installation is in progress, waiting... (${waited}s)"
sleep 10
waited=$((waited + 10))
done
# Store PID for debugging
echo $$ > "${LOCK_FILE}/pid"
trap release_lock EXIT
}
release_lock() {
rm -rf "${LOCK_FILE}" 2>/dev/null || true
}
# Re-check after acquiring lock (another process may have installed it)
acquire_lock
if check_modules_installed; then
echo "✓ Unity ${UNITY_VERSION} with ${PLATFORM} support was installed while waiting for lock"
exit 0
fi
echo ""
# Determine modules needed for this platform
get_modules_for_platform() {
case "${PLATFORM}" in
mac)
echo "mac-il2cpp"
;;
windows)
echo "windows-mono"
;;
ios)
echo "ios"
;;
all)
echo "mac-il2cpp windows-mono ios"
;;
*)
echo "Unknown platform: ${PLATFORM}" >&2
echo "Valid platforms: mac, windows, ios, all" >&2
exit 1
;;
esac
}
MODULES=$(get_modules_for_platform)
# Check if editor is already installed (just missing modules)
if [ -d "${UNITY_INSTALL_PATH}/${UNITY_VERSION}" ]; then
echo "Unity ${UNITY_VERSION} is installed but missing modules. Adding modules..."
echo ""
# Use install-modules to add modules to existing installation
INSTALL_CMD=("${UNITY_HUB_CLI}" -- --headless install-modules --version "${UNITY_VERSION}")
for mod in $MODULES; do
INSTALL_CMD+=(--module "$mod")
done
else
echo "Attempting to install Unity ${UNITY_VERSION} via Unity Hub CLI..."
echo ""
# Use install to install editor with modules
INSTALL_CMD=("${UNITY_HUB_CLI}" -- --headless install --version "${UNITY_VERSION}")
for mod in $MODULES; do
INSTALL_CMD+=(--module "$mod")
done
fi
echo "Running: ${INSTALL_CMD[*]}"
echo ""
# Capture output to check for "already installed" messages
OUTPUT=$("${INSTALL_CMD[@]}" 2>&1) || true
EXIT_CODE=$?
echo "$OUTPUT"
# Check if modules are already installed (Unity Hub returns error but modules are present)
if echo "$OUTPUT" | grep -q "already installed\|No modules found to install"; then
echo ""
echo "✓ Modules already installed"
elif [ $EXIT_CODE -eq 0 ]; then
echo ""
echo "Unity Hub CLI command completed"
else
echo ""
echo "Unity Hub CLI command failed (exit code: ${EXIT_CODE})"
echo ""
echo "This may happen if:"
echo " - The Unity version is not available for download"
echo " - You need to log in to Unity Hub first"
echo " - Unity Hub requires a GUI interaction"
echo ""
echo "To install manually:"
echo " 1. Open Unity Hub"
echo " 2. Go to Installs -> Install Editor"
echo " 3. Select version ${UNITY_VERSION}"
echo " 4. Add modules: ${PLATFORM}"
exit 1
fi
# Verify installation
echo ""
if [ -d "${UNITY_INSTALL_PATH}/${UNITY_VERSION}" ]; then
echo "✓ Verified: Unity ${UNITY_VERSION} is now installed"
exit 0
else
echo "✗ Unity ${UNITY_VERSION} installation could not be verified"
echo " Expected path: ${UNITY_INSTALL_PATH}/${UNITY_VERSION}"
echo ""
echo "The installation may still be in progress, or may require manual intervention."
exit 1
fi
-65
View File
@@ -1,65 +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
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH="${COMMON_PATHS}:${PATH}"
MAX_ATTEMPTS=5
RETRY_DELAY=15
if [ -n "${GITHUB_PATH:-}" ]; then
for path in /opt/homebrew/bin /usr/local/bin; do
if [ -d "$path" ]; then
echo "$path" >> "$GITHUB_PATH"
fi
done
fi
if ! git lfs version >/dev/null 2>&1; then
if ! command -v brew >/dev/null 2>&1; then
echo "ERROR: git-lfs is unavailable, and Homebrew is not on PATH"
exit 1
fi
echo "Installing git-lfs with Homebrew"
brew install git-lfs
fi
echo "Using git-lfs at $(command -v git-lfs)"
git lfs version
git lfs install
echo "LFS objects before pull:"
git lfs ls-files | wc -l
GIT_LFS_PULL=(git lfs pull)
if [ -n "${GITHUB_TOKEN:-}" ]; then
BASIC_AUTH=$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')
GIT_LFS_PULL=(git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic $BASIC_AUTH" lfs pull)
fi
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
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
set -uxo pipefail
/bin/echo "persist Library/"
# rsync may exit with code 23 ("partial transfer due to error") if Unity's
# temporary files vanish during the copy. This is acceptable for a cache.
/usr/bin/rsync -rtlDvq src/main/csharp/net/eagle0/clients/unity/eagle0/Library/ /tmp/eagle0/Library/
rsync_exit=$?
if [ $rsync_exit -eq 0 ]; then
exit 0
elif [ $rsync_exit -eq 23 ]; then
echo "Warning: rsync exited with 23 (some files vanished during copy). This is expected for Unity temp files."
exit 0
else
echo "Error: rsync failed with exit code $rsync_exit"
exit $rsync_exit
fi
+7
View File
@@ -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/
-56
View File
@@ -1,56 +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: //')
# Use runner-specific build directory if EAGLE0_BUILD_DIR is set, otherwise default
BUILD_BASE="${EAGLE0_BUILD_DIR:-/tmp/eagle0}"
WORKSPACE=$(pwd)
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
LOG_PATH="${BUILD_BASE}/editor_editmode_tests.log"
RESULTS_PATH="${BUILD_BASE}/editmode-test-results.xml"
mkdir -p "$BUILD_BASE"
echo "Building protos"
./scripts/build_protos.sh
echo "Running Unity EditMode tests"
set +e
"${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity" \
-nographics \
-batchmode \
-quit \
-executeMethod eagle0.Tests.DynamicTextBindingTestRunner.Run \
-testResults "$RESULTS_PATH" \
-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 EditMode tests failed with exit code $UNITY_EXIT_CODE"
echo "=== Unity EditMode Test Log (last 200 lines) ==="
tail -200 "$LOG_PATH" || echo "Could not read log file at $LOG_PATH"
echo "=== End of Unity EditMode Test Log ==="
exit $UNITY_EXIT_CODE
fi
if [ ! -f "$LOG_PATH" ]; then
echo ""
echo "ERROR: Unity exited successfully but did not write an editor log at $LOG_PATH"
exit 1
fi
if [ ! -f "$RESULTS_PATH" ]; then
echo ""
echo "ERROR: Unity exited successfully but did not write test results at $RESULTS_PATH"
exit 1
fi
echo "Unity EditMode tests complete"
-44
View File
@@ -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/"
-102
View File
@@ -1,102 +0,0 @@
#!/usr/bin/env bash
# Upload xcarchive to TestFlight using App Store Connect API Key
# This replaces the deprecated altool which was removed in Xcode 14+
#
# Uses xcodebuild -exportArchive with destination=upload, which is Apple's
# recommended approach for CI/CD pipelines.
set -euxo pipefail
# Ensure xcodebuild uses Xcode.app, not Command Line Tools
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
ARCHIVE_PATH=${1:?Usage: upload_testflight.sh <xcarchive_path> <team_id> <profile_uuid>}
TEAM_ID=${2:?Missing team ID}
PROFILE_UUID=${3:?Missing provisioning profile UUID}
if [ ! -d "$ARCHIVE_PATH" ]; then
echo "Error: xcarchive not found: $ARCHIVE_PATH"
exit 1
fi
echo "Uploading to TestFlight: $ARCHIVE_PATH"
# Requires App Store Connect API Key:
# - APP_STORE_CONNECT_API_KEY_ID: Key ID from App Store Connect
# - APP_STORE_CONNECT_API_ISSUER_ID: Issuer ID from App Store Connect
# - APP_STORE_CONNECT_API_KEY_PATH: Path to .p8 private key file
#
# To create an API key:
# 1. Go to https://appstoreconnect.apple.com/access/api
# 2. Click the + button to create a new key
# 3. Give it a name and Admin or App Manager access
# 4. Download the .p8 file (you can only download it once!)
# 5. Note the Key ID and Issuer ID shown on the page
if [ -z "${APP_STORE_CONNECT_API_KEY_ID:-}" ]; then
echo "Error: APP_STORE_CONNECT_API_KEY_ID environment variable not set"
echo "Create an API key at https://appstoreconnect.apple.com/access/api"
exit 1
fi
if [ -z "${APP_STORE_CONNECT_API_ISSUER_ID:-}" ]; then
echo "Error: APP_STORE_CONNECT_API_ISSUER_ID environment variable not set"
exit 1
fi
if [ -z "${APP_STORE_CONNECT_API_KEY_PATH:-}" ]; then
echo "Error: APP_STORE_CONNECT_API_KEY_PATH environment variable not set"
exit 1
fi
if [ ! -f "$APP_STORE_CONNECT_API_KEY_PATH" ]; then
echo "Error: API key file not found: $APP_STORE_CONNECT_API_KEY_PATH"
exit 1
fi
# Create export options plist with upload destination
EXPORT_OPTIONS_PLIST=$(mktemp)
trap "rm -f $EXPORT_OPTIONS_PLIST" EXIT
cat > "$EXPORT_OPTIONS_PLIST" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>destination</key>
<string>upload</string>
<key>method</key>
<string>app-store-connect</string>
<key>teamID</key>
<string>$TEAM_ID</string>
<key>uploadSymbols</key>
<true/>
<key>signingStyle</key>
<string>manual</string>
<key>signingCertificate</key>
<string>Apple Distribution: Daniel Crosby (UWJ88DX8WQ)</string>
<key>provisioningProfiles</key>
<dict>
<key>net.eagle0.eagle</key>
<string>$PROFILE_UUID</string>
</dict>
</dict>
</plist>
EOF
echo "Export options:"
cat "$EXPORT_OPTIONS_PLIST"
echo "Uploading to App Store Connect..."
# Use xcodebuild to upload with App Store Connect API authentication
xcodebuild -exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportOptionsPlist "$EXPORT_OPTIONS_PLIST" \
-authenticationKeyPath "$APP_STORE_CONNECT_API_KEY_PATH" \
-authenticationKeyID "$APP_STORE_CONNECT_API_KEY_ID" \
-authenticationKeyIssuerID "$APP_STORE_CONNECT_API_ISSUER_ID"
echo "Upload complete! Check App Store Connect for processing status."
echo "The build should appear in TestFlight within 15-30 minutes after processing."
-57
View File
@@ -1,57 +0,0 @@
"""Split a JVM binary's runtime classpath into first-party and third-party jar sets.
This exists so the Docker image can place rarely-changing third-party jars in a
lower OCI layer and frequently-changing first-party jars in a small top layer,
so most pushes only re-upload the small layer.
"""
def _unique_name(jar):
# short_path is unique per jar and stable across commits (it depends only on
# the jar's own package/coordinate, not on unrelated targets), so the deps
# layer's tar entries stay byte-identical and its blob digest stays cached.
path = jar.short_path
if path.startswith("../"):
path = path[3:]
return path.replace("+", "_").replace("/", "_").replace("~", "_")
def _impl(ctx):
info = ctx.attr.binary[JavaInfo]
app = []
deps = []
seen = {}
for jar in sorted(info.transitive_runtime_jars.to_list(), key = lambda f: f.path):
workspace = jar.owner.workspace_name if jar.owner else ""
bucket = "app" if workspace == "" else "deps"
out_name = _unique_name(jar)
key = bucket + "/" + out_name
if key in seen:
fail("jar_split: duplicate output name %r for %s and %s" % (
key,
seen[key],
jar.path,
))
seen[key] = jar.path
link = ctx.actions.declare_file(ctx.label.name + "/" + key)
ctx.actions.symlink(output = link, target_file = jar)
(app if bucket == "app" else deps).append(link)
return [
DefaultInfo(files = depset(app + deps)),
OutputGroupInfo(app = depset(app), deps = depset(deps)),
]
jar_split = rule(
implementation = _impl,
doc = "Partitions a JVM binary's transitive runtime jars into 'app' " +
"(first-party, empty workspace) and 'deps' (third-party) output groups.",
attrs = {
"binary": attr.label(
mandatory = True,
providers = [[JavaInfo]],
doc = "A jvm binary/library target whose runtime classpath to split.",
),
},
)
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
UNITY_VERSION='6000.3.0f1'
+40
View File
@@ -0,0 +1,40 @@
# Environment template for production deployment
# This file defines all env vars used by docker-compose.prod.yml
# Workflows should update their specific vars without overwriting others
# Container images (managed by respective build workflows)
# Note: Shardok runs on Hetzner, deployed via shardok_arm64_build.yml
EAGLE_IMAGE=registry.digitalocean.com/eagle0/eagle-server:latest
ADMIN_IMAGE=registry.digitalocean.com/eagle0/admin-server:latest
JFR_SIDECAR_IMAGE=registry.digitalocean.com/eagle0/jfr-sidecar:latest
AUTH_IMAGE=registry.digitalocean.com/eagle0/auth-server:latest
# OpenAI / LLM
OPENAI_API_KEY=
GPT_MODEL_NAME=gpt-4o
# DigitalOcean Spaces (S3-compatible storage)
EAGLE_ENABLE_S3=false
DO_SPACES_ACCESS_KEY=
DO_SPACES_SECRET_KEY=
# JWT authentication
JWT_PRIVATE_KEY=
# OAuth providers
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Shardok connection (Hetzner ARM64 server)
SHARDOK_ADDRESS=
SHARDOK_AUTH_TOKEN=
# Monitoring
SENTRY_DSN=
# Email (Fastmail JMAP)
FASTMAIL_API_TOKEN=
FASTMAIL_FROM_EMAIL=
FASTMAIL_FROM_NAME=
+59
View File
@@ -0,0 +1,59 @@
#!/bin/bash
# Update .env file without losing other variables
# Usage: ./update-env.sh KEY1=value1 KEY2=value2 ...
#
# This script:
# 1. Creates .env from template if it doesn't exist
# 2. Updates only the specified KEY=value pairs
# 3. Preserves all other existing values
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ENV_FILE="${ENV_FILE:-/opt/eagle0/.env}"
TEMPLATE_FILE="${TEMPLATE_FILE:-$SCRIPT_DIR/env.template}"
# Create .env from template if it doesn't exist
if [ ! -f "$ENV_FILE" ]; then
if [ -f "$TEMPLATE_FILE" ]; then
echo "Creating .env from template..."
grep -v '^#' "$TEMPLATE_FILE" | grep -v '^$' > "$ENV_FILE"
else
echo "Creating empty .env..."
touch "$ENV_FILE"
fi
chmod 600 "$ENV_FILE"
fi
# Process each KEY=VALUE argument
for arg in "$@"; do
# Skip empty args
[ -z "$arg" ] && continue
# Parse KEY=VALUE
KEY="${arg%%=*}"
VALUE="${arg#*=}"
# Skip if no key
[ -z "$KEY" ] && continue
# Skip setting empty values (keeps existing value)
if [ -z "$VALUE" ]; then
echo "Skipping $KEY (empty value)"
continue
fi
# Remove existing line for this key and add new one
if grep -q "^${KEY}=" "$ENV_FILE" 2>/dev/null; then
# Key exists, update it
sed -i "s|^${KEY}=.*|${KEY}=${VALUE}|" "$ENV_FILE"
echo "Updated $KEY"
else
# Key doesn't exist, add it
echo "${KEY}=${VALUE}" >> "$ENV_FILE"
echo "Added $KEY"
fi
done
chmod 600 "$ENV_FILE"
echo "Done updating $ENV_FILE"
+14 -49
View File
@@ -18,29 +18,23 @@ services:
image: ${EAGLE_IMAGE:-registry.digitalocean.com/eagle0/eagle-server:latest}
container_name: eagle-blue
command:
- "--gpt-model-name"
- "${GPT_MODEL_NAME:-gpt-5.1}"
- "--shardok-interface-remote-address"
- "${SHARDOK_ADDRESS}"
- "${SHARDOK_ADDRESS:-shardok:40042}"
- "--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:-}"
EAGLE_HISTORY_BACKEND: "${EAGLE_HISTORY_BACKEND:-postgres}"
EAGLE_POSTGRES_HOST: "${EAGLE_POSTGRES_HOST:-}"
EAGLE_POSTGRES_PORT: "${EAGLE_POSTGRES_PORT:-}"
EAGLE_POSTGRES_DATABASE: "${EAGLE_POSTGRES_DATABASE:-}"
EAGLE_POSTGRES_USER: "${EAGLE_POSTGRES_USER:-}"
EAGLE_POSTGRES_PASSWORD: "${EAGLE_POSTGRES_PASSWORD:-}"
EAGLE_POSTGRES_SSLMODE: "${EAGLE_POSTGRES_SSLMODE:-require}"
# JWT public key for token validation (auth service handles signing)
# Reads from /etc/eagle0/keys/public.pem via shared volume
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
# Auth token for Shardok on Hetzner (required)
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
# Use persistent volume for save data (users, games, etc.)
@@ -53,7 +47,7 @@ services:
- ./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)
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
depends_on:
- auth
restart: unless-stopped
@@ -74,29 +68,23 @@ services:
container_name: eagle-green
profiles: ["blue-green"] # Only started during blue-green deployment
command:
- "--gpt-model-name"
- "${GPT_MODEL_NAME:-gpt-5.1}"
- "--shardok-interface-remote-address"
- "${SHARDOK_ADDRESS}"
- "${SHARDOK_ADDRESS:-shardok:40042}"
- "--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:-}"
EAGLE_HISTORY_BACKEND: "${EAGLE_HISTORY_BACKEND:-postgres}"
EAGLE_POSTGRES_HOST: "${EAGLE_POSTGRES_HOST:-}"
EAGLE_POSTGRES_PORT: "${EAGLE_POSTGRES_PORT:-}"
EAGLE_POSTGRES_DATABASE: "${EAGLE_POSTGRES_DATABASE:-}"
EAGLE_POSTGRES_USER: "${EAGLE_POSTGRES_USER:-}"
EAGLE_POSTGRES_PASSWORD: "${EAGLE_POSTGRES_PASSWORD:-}"
EAGLE_POSTGRES_SSLMODE: "${EAGLE_POSTGRES_SSLMODE:-require}"
# JWT public key for token validation (auth service handles signing)
# Reads from /etc/eagle0/keys/public.pem via shared volume
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
EAGLE_SAVE_DIR: "/app/saves"
EAGLE_ARCHIVE_DIR: "/app/archived"
@@ -107,7 +95,7 @@ services:
- ./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)
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
depends_on:
- auth
restart: "no" # Don't auto-restart during deployment
@@ -145,16 +133,6 @@ services:
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
@@ -222,19 +200,6 @@ services:
- "${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
-242
View File
@@ -1,242 +0,0 @@
# Adding New Quest Types
This document describes how to add new quest types to Eagle. Quests are tasks that unaffiliated heroes want factions to complete before they'll join.
## Deployment Strategy: Three-PR Approach
When adding new quest types, use a three-PR strategy to ensure clients never see quests they don't understand:
1. **PR 1 - Types Only (Server)**: Add proto definitions, Scala types, converters, and all handling logic (fulfillment, failure, LLM prompts). Do NOT generate the quests yet.
2. **PR 2 - Client Handling**: Add client-side display code (C#/Unity) that can render the new quest type.
3. **PR 3 - Quest Generation (Server)**: Add the actual quest creation logic to `QuestCreationUtils.scala`.
**PR Dependencies:**
```
PR 1 (types)
├── PR 2 (client)
└── PR 3 (generation)
```
PR 2 and PR 3 both depend on PR 1, but are independent of each other. They can be developed in parallel after PR 1 is merged, and merged in either order. The key constraint is that PR 3 should not be deployed before PR 2, so clients understand the quest type before the server starts generating it.
## Files to Modify
### Server-Side (Scala)
#### 1. Proto Definition
**File:** `src/main/protobuf/net/eagle0/eagle/common/unaffiliated_hero_quest.proto`
Add a new message type for your quest and add it to the `QuestDetails` oneof:
```protobuf
message MyNewQuest {
int32 some_field = 1;
string another_field = 2;
}
// In the QuestDetails message, add to the oneof:
oneof sealed_value {
// ... existing quests ...
MyNewQuest my_new_quest = XX; // Use next available field number
}
```
#### 2. Scala Case Class
**File:** `src/main/scala/net/eagle0/eagle/model/state/quest/Quest.scala`
Add a case class (or case object for quests with no parameters):
```scala
case class MyNewQuest(
someField: Int,
anotherField: String
) extends Quest
// For quests with multi-part completion, extend ComponentQuest:
case class MyComponentQuest(
override val componentCount: Int,
override val componentsFulfilled: Int,
targetValue: Int
) extends ComponentQuest {
override def withComponentsFulfilled(componentsFulfilled: Int): Quest =
this.copy(componentsFulfilled = componentsFulfilled)
}
```
#### 3. Proto Converter
**File:** `src/main/scala/net/eagle0/eagle/model/proto_converters/QuestConverter.scala`
Add conversions in both `toProto` and `fromProto` methods:
```scala
// In toProto:
case q: MyNewQuest =>
QuestProto(details = MyNewQuestProto(q.someField, q.anotherField))
// In fromProto:
case MyNewQuestProto(someField, anotherField, _) =>
MyNewQuest(someField, anotherField)
```
#### 4. Quest Fulfillment Check
**File:** `src/main/scala/net/eagle0/eagle/library/actions/impl/action/CheckForFulfilledQuestsAction.scala`
Add a case to `didFulfillQuest` that returns `true` when the quest conditions are met:
```scala
case MyNewQuest(someField, anotherField) =>
// Return true if quest is fulfilled
someConditionIsMet(province, someField)
```
For `ComponentQuest` subclasses, the base case `case q: ComponentQuest => q.componentsFulfilled >= q.componentCount` handles fulfillment automatically.
#### 5. Quest Failure Check
**File:** `src/main/scala/net/eagle0/eagle/library/actions/impl/action/CheckForFailedQuestsAction.scala`
Add a case to `isQuestFailed` if your quest can fail (e.g., if a required faction is destroyed):
```scala
case MyNewQuest(someField, _) =>
// Return true if quest can no longer be completed
!factionWithId(someField).exists(_.isActive)
```
Many quests use the default case that returns `false` (quest never fails automatically).
#### 6. LLM Prompt Generators
**File:** `src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators/DivineMessagePromptGenerator.scala`
Add a case to `describeQuest` for when a soothsayer reveals the quest:
```scala
case MyNewQuest(someField, anotherField) =>
TextGenerationSuccess(
s"$divinedHeroName wants ${faction.name} to do something with $someField."
)
```
**File:** `src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators/QuestEndedGeneratorUtilities.scala`
Add a case to `pastTenseQuestDescription` for quest fulfilled/failed narratives:
```scala
case MyNewQuest(someField, anotherField) =>
for {
unaffiliatedHeroName <- unaffiliatedHeroNameResult
} yield s"$unaffiliatedHeroName wanted ${faction.name} to do something with $someField."
```
#### 7. Quest Creation (PR 3 only)
**File:** `src/main/scala/net/eagle0/eagle/library/util/quest_creation/QuestCreationUtils.scala`
Add a quest creator function and register it in `availableQuests`:
```scala
private def myNewQuests(
province: ProvinceT,
allProvinces: Vector[ProvinceT],
@unused factions: Vector[FactionT],
@unused battalions: Vector[BattalionT],
functionalRandom: FunctionalRandom
): RandomState[Vector[Quest]] = {
// Return empty if quest shouldn't be available
if (!someCondition) RandomState(Vector(), functionalRandom)
else {
functionalRandom.nextIntInclusive(minValue, maxValue).map { value =>
Vector(MyNewQuest(value, "something"))
}
}
}
// Add to availableQuests list:
def availableQuests(...) = functionalRandom.nextFlatMap(
Vector[QuestCreator](
// ... existing creators ...
myNewQuests
)
) { ... }
```
#### 8. Optional: Quest Command Selectors (AI auto-completion)
**Directory:** `src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/quest_command_selectors/`
If you want AI players to automatically work toward completing your quest, create a command chooser.
### Client-Side (C#/Unity)
#### 1. Quest Type Display Name
**File:** `src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/DisplayNames.cs`
Add a case to `QuestTypeString`:
```csharp
case SealedValueOneofCase.MyNewQuest: return "My New Quest";
```
#### 2. Quest Description String
**File:** `src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/Table Rows/UnaffiliatedHeroRowController.cs`
Add a case to `ShortQuestString` for displaying the quest in the UI:
```csharp
case SealedValueOneofCase.MyNewQuest: {
var details = quest.Details.MyNewQuest;
return $"Do something with {details.SomeField}";
}
```
For quests involving heroes (where you want dynamic name updates), add a case to `SetQuestText` instead.
### Build Files
After making changes, run:
```bash
bazel run gazelle
```
This updates BUILD.bazel files with any new dependencies.
## Quest Type Categories
### Simple Quests
Quests with fixed completion conditions (e.g., `AllianceQuest`, `SuppressRiotByForceQuest`).
### Component Quests
Quests with multi-part completion that track progress via `componentsFulfilled` / `componentCount` (e.g., `AlmsToProvinceQuest`, `DevelopProvincesQuest`). Extend `ComponentQuest` and implement `withComponentsFulfilled`.
### Action Quests
Quests completed by specific player actions rather than state checks. Return `false` in `didFulfillQuest` and handle completion in the relevant command handler (e.g., `ExecutePrisonerQuest` is fulfilled in prisoner management command).
## Testing
1. Build all modified targets:
```bash
bazel build //src/main/scala/net/eagle0/eagle/model/state/quest:quest
bazel build //src/main/scala/net/eagle0/eagle/model/proto_converters:quest_converter
```
2. Run tests:
```bash
bazel test //src/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
+471
View File
@@ -0,0 +1,471 @@
# Admin Server Enhancement Plan
## Overview
This document outlines enhancements to the Go admin server (`src/main/go/net/eagle0/admin_server/`) to provide a proper web UI for game administration.
### Current State
The admin server provides a full web UI with htmx interactivity:
- `GET /` - Redirect to games list
- `GET /games` - Game list page (HTML)
- `GET /games/{id}` - Game detail with action history
- `GET /games/{id}/history` - History rows (htmx partial, infinite scroll)
- `GET /games/{id}/action/{index}` - Action detail (htmx partial)
- `POST /games/{id}/rewind` - Rewind game to target action
- `GET /settings` - Settings list with live search
- `POST /settings/update` - Update setting value
- `GET /health` - Health check (JSON)
- `GET /api/games` - JSON API for programmatic access
- `GET /api/games/{id}/history` - JSON API for history
### Goals
1. **Web UI**: Replace raw JSON with an interactive HTML interface
2. **Settings Management**: View and modify the 275+ game settings at runtime
3. **Game Rewind**: Restore a game to a previous action count
---
## Architecture
### Technology Choice: Go Templates + htmx
**Rationale:**
- Single binary deployment (no separate frontend build)
- htmx provides interactivity without JavaScript framework complexity
- Familiar HTML/CSS, minimal learning curve
- Excellent for admin tools where SEO and bundle size don't matter
**Alternatives Considered:**
- React/Vue SPA: Adds build complexity, separate deployment artifact
- Server-side only: Less interactive, full page reloads
### Directory Structure
```
src/main/go/net/eagle0/admin_server/
├── admin_server.go # Main entry point, HTTP routes
├── handlers/
│ ├── games.go # Game list and detail handlers
│ ├── settings.go # Settings list and update handlers
│ └── rewind.go # Game rewind handlers
├── templates/
│ ├── layout.html # Base layout with nav, htmx includes
│ ├── games/
│ │ ├── list.html # Game list page
│ │ ├── detail.html # Single game view with history
│ │ └── history.html # Partial for history table (htmx)
│ ├── settings/
│ │ ├── list.html # Settings list with search/filter
│ │ └── edit.html # Inline edit partial (htmx)
│ └── rewind/
│ └── confirm.html # Rewind confirmation modal
├── static/
│ ├── style.css # Minimal CSS (Pico CSS or similar)
│ └── htmx.min.js # htmx library
└── BUILD.bazel
```
---
## Feature 1: Web UI
### Routes
| Route | Method | Description |
|-------|--------|-------------|
| `/` | GET | Redirect to `/games` |
| `/games` | GET | Game list page (HTML) |
| `/games/{id}` | GET | Game detail page with history |
| `/games/{id}/history` | GET | History partial (htmx, for infinite scroll) |
| `/api/games` | GET | JSON API (existing, keep for programmatic access) |
| `/api/games/{id}/history` | GET | JSON API (existing) |
### Game List Page
```
┌─────────────────────────────────────────────────────────────┐
│ Eagle Admin [Settings] [Health] │
├─────────────────────────────────────────────────────────────┤
│ │
│ Running Games (3) │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Game abc123f Round 45 │ │
│ │ Players: Liu Bei (Human), Cao Cao (AI), Sun Quan │ │
│ │ Actions: 1,234 [View] [Rewind]│ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Game def456a Round 12 │ │
│ │ Players: Test Player (Human) │ │
│ │ Actions: 456 [View] [Rewind]│ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Game Detail Page
Shows game info and scrollable action history:
- **Reverse chronological order**: Most recent actions displayed first
- Each action shows: index, type, round ID
- **Clickable actions**: Clicking an action row expands to show JSON representation of the full action data
- "Rewind to here" button on each action row
- Infinite scroll loads more history via htmx (loading older actions as user scrolls down)
### Implementation Notes
1. **Embed static files**: Use `//go:embed` to bundle templates and static files
2. **Template functions**: Add helpers for formatting (hex IDs, timestamps, action summaries)
3. **CSS framework**: Use Pico CSS (~10KB) for clean defaults without classes
---
## Feature 2: Settings Management
### New gRPC Endpoints (Eagle Server)
Add to `eagle.proto`:
```protobuf
message Setting {
string name = 1;
string type = 2; // "Int" or "Double"
string value = 3; // Current value as string
string default_value = 4; // Default from BUILD.bazel
string description = 5; // Optional, for UI hints
}
message GetSettingsRequest {
string filter = 1; // Optional name filter (substring match)
}
message GetSettingsResponse {
repeated Setting settings = 1;
}
message UpdateSettingRequest {
string name = 1;
string value = 2;
}
message UpdateSettingResponse {
Setting setting = 1; // Updated setting
string error = 2; // Empty on success
}
service Eagle {
// ... existing methods ...
rpc GetSettings(GetSettingsRequest) returns (GetSettingsResponse);
rpc UpdateSetting(UpdateSettingRequest) returns (UpdateSettingResponse);
}
```
### Eagle Server Implementation
Create a settings registry that:
1. Discovers all `IntSetting` and `DoubleSetting` instances via reflection or explicit registration
2. Provides get/set by name
3. Validates types on update
```scala
// src/main/scala/net/eagle0/eagle/library/settings/SettingsRegistry.scala
object SettingsRegistry {
private val settings: Map[String, Either[IntSetting, DoubleSetting]] = Map(
"ActionVigorCost" -> Left(ActionVigorCost),
"BaseFoodBuyPrice" -> Right(BaseFoodBuyPrice),
// ... register all 275 settings
)
def getAll(filter: Option[String]): Seq[Setting] = ...
def get(name: String): Option[Setting] = ...
def update(name: String, value: String): Either[String, Setting] = ...
}
```
**Alternative: Code generation**
Rather than manually registering 275 settings, modify `setting_rule.bzl` to generate a registry file during build.
### Admin Server Routes
| Route | Method | Description |
|-------|--------|-------------|
| `/settings` | GET | Settings list page with search |
| `/settings/{name}` | GET | Single setting detail (htmx partial) |
| `/settings/{name}` | PUT | Update setting value |
| `/api/settings` | GET | JSON API |
| `/api/settings/{name}` | PUT | JSON API |
### Settings UI
```
┌─────────────────────────────────────────────────────────────┐
│ Eagle Admin [Games] [Health] │
├─────────────────────────────────────────────────────────────┤
│ │
│ Settings [Search: __________ ] │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ActionVigorCost (Int) │ │
│ │ Current: [15 ] Default: 15 [Save] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ BaseFoodBuyPrice (Double) │ │
│ │ Current: [0.5 ] Default: 0.5 [Save] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ... (275 settings, virtualized/paginated) ... │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Considerations
1. **Persistence**: Settings changes are in-memory only. Document that restarts reset to defaults.
2. **Validation**: Validate numeric ranges where applicable (e.g., percentages 0-100)
3. **Categories**: Consider grouping settings by prefix (AI*, Combat*, Economy*, etc.)
4. **Audit log**: Log setting changes with timestamp for debugging
---
## Feature 3: Game Rewind
### Concept
Restore a game to a previous point in its action history. This is useful for:
- Debugging issues that occurred at a specific point
- Testing "what if" scenarios
- Recovering from bugs that corrupted state
### New gRPC Endpoint
Add to `eagle.proto`:
```protobuf
message RewindGameRequest {
int64 game_id = 1;
int32 target_action_count = 2; // Rewind to state after this many actions
}
message RewindGameResponse {
bool success = 1;
string error = 2;
int32 new_action_count = 3;
int32 disconnected_clients = 4; // Number of clients that were disconnected
}
service Eagle {
// ... existing methods ...
rpc RewindGame(RewindGameRequest) returns (RewindGameResponse);
}
```
### Eagle Server Implementation
The `GameHistory` already stores `ActionWithResultingState` for each action, which includes the `GameState` after that action. Rewinding means:
1. **Validate**: Check that `target_action_count` is within valid range (0 to current count)
2. **Get target state**: Retrieve `GameState` at target action count from history
3. **Disconnect clients**: Close all human player connections (they'll need to reconnect)
4. **Replace engine**: Create new `EngineImpl` with target state and truncated history
5. **Reset AI state**: Clear any cached AI state that depends on current game state
```scala
// GameController.scala (pseudocode)
def rewindTo(targetActionCount: Int): Either[String, RewindResult] = {
if (targetActionCount < 0 || targetActionCount > engine.history.count)
return Left(s"Invalid action count: $targetActionCount")
// Get state at target point
val targetState = engine.history.stateAt(targetActionCount)
val truncatedHistory = engine.history.truncateTo(targetActionCount)
// Disconnect all human clients
val disconnectedCount = humanClients.length
humanClients.foreach(_.disconnect("Game rewound by admin"))
// Create new engine at target state
val newEngine = EngineImpl(
gameId = engine.gameId,
currentState = targetState,
history = truncatedHistory,
// ... other fields
)
// Replace controller's engine
this.engine = newEngine
Right(RewindResult(targetActionCount, disconnectedCount))
}
```
### GameHistory Enhancement
Add method to get state at a specific action count:
```scala
trait GameHistory {
// ... existing methods ...
def stateAt(actionCount: Int): GameState = {
if (actionCount == 0) initialState
else all(actionCount - 1).resultingState
}
def truncateTo(actionCount: Int): GameHistory = {
GameHistoryImpl(
initialState = initialState,
actions = all.take(actionCount)
)
}
}
```
### Admin Server Route
| Route | Method | Description |
|-------|--------|-------------|
| `/games/{id}/rewind` | POST | Rewind game (form: `target_action_count`) |
| `/games/{id}/rewind/confirm` | GET | Confirmation modal (htmx partial) |
### Rewind UI Flow
1. User views game history
2. User clicks "Rewind to here" on an action row
3. Confirmation modal appears via htmx:
```
┌─────────────────────────────────────────┐
│ Rewind Game abc123f? │
│ │
│ This will: │
│ • Restore to action 456 (Round 23) │
│ • Discard 778 subsequent actions │
│ • Disconnect 2 connected players │
│ │
│ This cannot be undone. │
│ │
│ [Cancel] [Rewind] │
└─────────────────────────────────────────┘
```
4. On confirm, POST to `/games/{id}/rewind`
5. Success: redirect to game detail showing new state
6. Error: show error message
### Safety Considerations
1. **No undo**: Rewinding discards history. Consider optional backup before rewind.
2. **Client disconnect**: All connected clients are forcibly disconnected.
3. **AI state**: Ensure AI clients restart cleanly after rewind.
4. **Concurrent access**: Lock game during rewind to prevent race conditions.
5. **Authorization**: In production, require admin authentication.
---
## Implementation Phases
### Phase 1: Web UI Foundation
**Status: Complete**
1. ✅ Set up Go templates with `embed`
2. ✅ Add Pico CSS and htmx
3. ✅ Create base layout with navigation
4. ✅ Convert `/games` to HTML with styling
5. ✅ Add game detail page with history table
6. ✅ Implement htmx infinite scroll for history
7. ✅ Reverse history order (most recent first)
8. ✅ Clickable action rows that expand to show JSON representation
9. ✅ Add `/games/{id}/action/{index}` endpoint for fetching action details
**Deliverable**: Browsable game list and history in HTML with clickable action details
### Phase 2: Settings Management
**Status: Complete**
1. ✅ Add `GetSettings` to `eagle.proto` (uses existing `AddSettings` for updates)
2. ✅ Add `getAllSettings` method to auto-generated `SettingsLoader`
3. ✅ Implement `getSettings` in `EagleServiceImpl`
4. ✅ Create settings list page with live search
5. ✅ Add inline editing with htmx
6. ✅ Modified settings are highlighted
**Deliverable**: View and edit settings via admin UI
### Phase 3: Game Rewind
**Status: Complete**
1. ✅ Add `RewindGame` to `eagle.proto`
2. ✅ Implement `stateAt` and `truncateTo` in `GameHistory`
3. ✅ Implement rewind logic in `Engine` and `GameController`
4. ✅ Add rewind confirmation (htmx `hx-confirm` dialog)
5. ✅ Handle client disconnection gracefully
6. ✅ Add rewind button to history rows
7. ✅ Implement `rewindGame` in `GamesManager` and `EagleServiceImpl`
8. ✅ Add admin server `/games/{id}/rewind` POST handler
9. ✅ Add success/error feedback UI
**Deliverable**: Rewind games to any previous action
### Phase 4: Polish
**Status: Not Started**
#### High Priority
1. **Add tests for rewind functionality**
- `PersistedHistory.truncateTo` (handles complex persisted vs recent logic)
- `InMemoryHistory.truncateTo`
- `EngineImpl.rewindTo`
- `GameController.rewindTo`
- `GamesManager.rewindGame`
2. **Improve action history display**
- Human-readable action type names (e.g., "New Round" instead of "NewRoundAction")
- Show acting faction/province when available
- Action summaries from the `summary` field in `GameHistoryEntry`
#### Medium Priority
3. **Settings improvements**
- Group settings by category prefix (AI*, Combat*, Economy*, etc.)
- Show setting descriptions where available
- Pagination for large settings lists
4. **Error handling improvements**
- Better error messages on failed operations
- Retry logic for transient gRPC failures
#### Low Priority (Nice to Have)
5. **Basic auth** - HTTP Basic Auth or OAuth for production use
6. **Audit logging** - Log admin actions with timestamps
7. **Documentation** - Usage guide, deployment notes
#### Future Considerations
- Game creation from admin UI
- Player management (view connected players, force disconnect)
- Export game history to file
- Metrics/stats dashboard
---
## Security Notes
The admin server is intended for local/trusted network use only. For production:
1. **Do not expose to public internet** without authentication
2. Consider adding HTTP Basic Auth or OAuth
3. Run on internal network or behind VPN
4. Log all admin actions for audit trail
---
## Open Questions
1. **Settings persistence**: Should we add optional persistence to disk/database?
2. **Game snapshots**: Should rewind create a backup first?
3. **Multi-admin**: Need locking if multiple admins access simultaneously?
4. **Shardok settings**: Are there Shardok (C++) settings to expose too?
-281
View File
@@ -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` |
+52 -142
View File
@@ -12,7 +12,7 @@ This document catalogs all media assets in the Unity project for licensing revie
|----------|-------|-------|
| 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 |
| 3D Models | 52 | Bridge pack only |
| Fonts | 16 | TTF files |
---
@@ -55,30 +55,6 @@ These are commercial Unity Asset Store purchases tied to your account:
- **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
@@ -133,41 +109,11 @@ All 26 tracks have CC licenses with proper attribution:
| 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/) |
**Tracks without specific license (verify):**
- Market Day
- Shopping List
- Medieval: Victory Theme
- Tracks by Dima Koltsov (AUDIUS): No Time for Greatness, Warriors of Demacia, Forest Queen Tale, Valor, Clouds
---
@@ -183,64 +129,42 @@ All 26 tracks have CC licenses with proper attribution:
## 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` |
### Stock Images (Possible License Issues)
These appear to be stock images that may have been used as placeholders:
| File | Concern |
|------|---------|
| ~~`Assets/Eagle/79066358-stock-illustration-raster-illustration-medieval-purse-bag...jpg`~~ | **DELETED** (2025-01-04) |
| ~~`Assets/Images/kisspng-hammer-hand-saws-tool-clip-art...jpg`~~ | **DELETED** (2025-01-04) |
| ~~`Assets/Images/lee-ermy-cropped.jpg`~~ | **DELETED** (2025-01-04) |
| ~~`Assets/Eagle/images.jpeg`~~ | **DELETED** (2025-01-04) |
### Clip Art (Unknown License)
| File | Concern |
|------|---------|
| `Assets/Shardok/commandImages/bridge.png` | Clip art style wooden bridge, unknown source - **needs replacement** |
| `Assets/Images/startFire.png` | Icon, unknown source - **needs verification or replacement** |
### Shardok Sound Effects
- **Location:** `Assets/Shardok/soundEffects/`
- **Count:** 37 audio files (was incorrectly counted as 56 including .meta files)
- **Count:** 56 MP3 files
- **Contents:** Spell effects, movement, combat sounds
- **Status:** Unknown origin - may be custom or need verification
**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`
### Free Icons
- **Location:** `Assets/free_icons/`
- **Count:** 8 PNG weather icons
- **Status:** Verify "free" means commercially usable
**⚠️ 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
### Terrain Hexes
- **Location:** `Assets/Terrain Hexes/`
- **Count:** 85 PNG files
- **Status:** ✓ Confirmed Unity Asset Store purchase (2026-01-23)
- **Status:** Unknown source - verify licensing
### ~~StrategyGameIcons~~ VERIFIED
### StrategyGameIcons
- **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)
- **Status:** Unknown source - verify licensing
---
@@ -270,31 +194,28 @@ NuGet packages in `Assets/Packages/` all include LICENSE.TXT files:
## Action Items
### Must Replace Before Opening Public Access:
### Must Verify Before Opening Public Access:
1. ~~**Clip art images**~~ - **RESOLVED** (2026-01-23): Replaced with properly licensed alternatives
1. ~~**Stock images** - The JPG files with stock image filenames need review.~~ **DONE** - Deleted lee-ermy, kisspng, stock-illustration, Yosemite Sam, and images.jpeg (2025-01-04)
2. ~~**Shardok sound effects**~~ - **ALL RESOLVED**:
- ~~`anybody.mp3`~~ - **REPLACED** (2026-01-23)
- ~~`burnination.mp3`~~ - **REPLACED** (2026-01-23)
- ~~`failure_horn.mp3`~~ - **REPLACED** (2026-01-27) with `Negative Effect 04.wav` from Magic Spells Sound Effects LITE
- ~~`runaway.mp3`~~ - **REPLACED** (2026-01-27) with Freesound CC BY 4.0
2. **Clip art images** - Unknown license, need replacement with properly licensed alternatives:
- `Assets/Shardok/commandImages/bridge.png` - wooden bridge icon
- `Assets/Images/startFire.png` - fire icon
### Low Priority (Verify):
3. **Shardok sound effects** - 56 MP3 files of unknown origin. Either:
- Document their source
- Replace with known-licensed alternatives
- Confirm they were custom-created
3. **Dima Koltsov tracks** - 2 of 5 not verified: `Forest Queen Tale`, `Clouds` (presumed CC BY 4.0 like his other tracks)
4. **Terrain Hexes** - 85 hex tiles of unknown source
- **TODO:** Confirm if this is a Unity Asset Store purchase (owner believes it is)
### Already Resolved:
5. **StrategyGameIcons** - 138 icons of unknown source
- **TODO:** Investigate origin - check Unity Asset Store purchase history
4. ~~**Free Icons**~~ - **RESOLVED** (2026-01-27): All replaced with Asset Store equivalents, folder deleted
6. **AUDIUS music tracks** - Verify Dima Koltsov tracks are licensed for commercial use
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
7. **Discord logo** (`Eagle/Discord-Logo-Blurple.png`) - Likely fine for "Login with Discord" button per Discord brand guidelines, but verify usage complies with their terms
### Already Safe:
@@ -308,23 +229,12 @@ NuGet packages in `Assets/Packages/` all include LICENSE.TXT files:
## Recommendation
**Remaining before public release:**
Before removing HTTP basic auth:
None! All required items resolved.
**Low priority:**
2. Verify 2 Dima Koltsov tracks (`Forest Queen Tale`, `Clouds`) - presumed CC BY 4.0
**Already resolved:**
- ~~Clip art images~~ **DONE** - replaced with properly licensed alternatives
- ~~Terrain Hexes~~ **DONE** - confirmed Asset Store purchase
- ~~StrategyGameIcons~~ **DONE** - Unity Asset Store (REXARD)
- ~~Medieval: Victory Theme~~ **DONE** - CC0 Public Domain
- ~~3 other Dima Koltsov tracks~~ **DONE** - confirmed CC BY 4.0
- ~~anybody.mp3, burnination.mp3~~ **DONE** - replaced
- ~~free_icons~~ **DONE** - replaced with Asset Store equivalents
- ~~failure_horn.mp3~~ **DONE** - replaced with Negative Effect 04.wav
1. ~~Delete or replace the 4 suspicious JPG/JPEG files in `Assets/Eagle/` and `Assets/Images/`~~ **DONE** (2025-01-04)
2. Replace clip art images (`bridge.png`, `startFire.png`) with properly licensed alternatives (e.g., from [game-icons.net](https://game-icons.net) CC BY 3.0)
3. Verify source of `Assets/Shardok/soundEffects/` MP3s
4. Verify source of `Assets/Terrain Hexes/` and `Assets/StrategyGameIcons/`
5. If any are from early development with unclear licensing, replace them
The bulk of your assets (95%+) are properly licensed Asset Store purchases or CC content.
-246
View File
@@ -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 `Assets/Scenes/Eagle.unity`, find the `ProvinceBeastsController` component on the Eagle map object 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
+388
View File
@@ -0,0 +1,388 @@
# Deproto Migration Plan
## Vision
**Protocol buffers should only be used at the edges** — for network serialization (gRPC) and disk persistence. Inside the Eagle game engine, all logic should operate on native Scala models.
```
┌─────────────────────────────────────────────────────────────────────┐
│ GRPC BOUNDARY │
│ EagleServiceImpl.scala ←→ Proto Messages ←→ Unity Client │
└─────────────────────────────────────────────────────────────────────┘
GameStateConverter
┌─────────────────────────────────────────────────────────────────────┐
│ SCALA ENGINE │
│ │
│ GameStateC ───→ Actions ───→ ActionResultT ───→ New GameStateC │
│ ↑ │ │
│ │ (Pure Scala models) │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ HeroC, FactionC, ProvinceC, BattalionC, ArmyC, etc. │
└─────────────────────────────────────────────────────────────────────┘
GameStateConverter
┌─────────────────────────────────────────────────────────────────────┐
│ PERSISTENCE BOUNDARY │
│ GameHistory.scala ←→ Proto Messages ←→ File/Database │
└─────────────────────────────────────────────────────────────────────┘
```
---
## Current State
### Completed Phases
| Phase | Status | Summary |
|-------|--------|---------|
| Phase 1: GameStateC | **Complete** | Scala `GameState` model with 22 fields |
| Phase 2: EngineImpl | **Complete** | Holds Scala `GameState` internally |
| Phase 3: GameHistory | **Complete** | `stateAfter` returns Scala GameState |
| Phase 4: ActionResultT | **Complete** | All 59 actions return `ActionResultT` |
| Phase 5: Action Base Classes | **Complete** | All `RandomSequentialResultsAction` and `DeterministicSingleResultAction` converted to T-type base classes |
| Phase 5b: Base Class Cleanup | **Complete** | `RandomSequentialResultsAction` and `DeterministicSingleResultAction` deleted |
| Phase 5c: RoundPhaseAdvancer Actions | **Complete** | All actions called by RoundPhaseAdvancer accept Scala GameState |
| Phase 5d: RoundPhaseAdvancer Itself | **Complete** | RoundPhaseAdvancer.checkForPhaseAdvancement takes Scala GameState |
### Phase 5c/5d Progress (Complete)
`RoundPhaseAdvancer.checkForPhaseAdvancement` now accepts Scala `GameState` and `ActionResultApplier` directly (PR #4677).
| Action | PR | Status |
|--------|-----|--------|
| `PrisonerExchangeAction` | #4670 | ✅ Merged |
| `PerformForcedTurnBackAction` | #4671 | ✅ Merged |
| `PerformHeroDeparturesAction` | #4672 | ✅ Merged |
| `RequestFreeForAllBattlesAction` | #4673 | ✅ Merged |
| `EndPlayerCommandsPhaseAction` | #4674 | ✅ Merged |
| `EndDiplomacyResolutionPhaseAction` | #4675 | ✅ Merged |
| `RoundPhaseAdvancer` itself | #4677 | ✅ Merged |
### EngineImpl Progress
| Change | PR | Status |
|--------|-----|--------|
| `recursiveTransform` deleted | #4677 | ✅ Merged |
| `recursiveTransformT` uses `RandomStateTSequencer` | #4677 | ✅ Merged |
### Current Architecture
**ActionResultT Production (100% Complete):**
- All actions produce `ActionResultT`
- Conversion to `ActionResultProto` happens via `ActionResultProtoConverter.toProto()`
- No direct `ActionResultProto` construction outside the converter
**ActionResultProto Consumption (Next Target):**
- `ActionResultProtoApplierImpl` - applies proto results to proto GameState
- `RoundPhaseAdvancer` - calls converter, passes protos to applier
- `InMemoryHistory` / `PersistedHistory` - stores proto results
- Service layer (`GameController`, `GamesManager`, etc.) - uses proto for client communication
---
## Phase 6: Migrate to ActionResultT Consumers
### Objective
Eliminate internal consumption of `ActionResultProto`. Everything inside the engine should work with `ActionResultT`.
### Current Flow (Proto-Heavy)
```
Action.execute()
→ ActionResultT
→ ActionResultProtoConverter.toProto()
→ ActionResultProto
→ ActionResultProtoApplierImpl.applyActionResults()
→ GameStateProto
→ GameStateConverter.fromProto()
→ GameStateC
```
### Target Flow (T-Types Throughout)
```
Action.execute()
→ ActionResultT
→ ActionResultApplier.applyActionResults()
→ GameStateC
(Proto conversion only at boundaries)
```
### Key Files to Convert
**Tier 1 - Core Applier:****Complete**
```
src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultApplierImpl.scala
```
`ActionResultApplier` applies `ActionResultT` directly to Scala `GameState`. The legacy `ActionResultTApplierImpl` wraps it and converts to/from proto for callers that still need proto types.
**Tier 2 - RoundPhaseAdvancer:****Complete**
```
src/main/scala/net/eagle0/eagle/library/RoundPhaseAdvancer.scala
```
Now accepts Scala `GameState` and `ActionResultApplier`. Only converts to proto lazily for `AvailableCommandsFactory` calls.
**Tier 3 - Sequencers:**
```
src/main/scala/net/eagle0/eagle/library/actions/impl/common/RandomStateTSequencer.scala
src/main/scala/net/eagle0/eagle/library/actions/impl/common/RandomStateProtoSequencer.scala
```
Modify `RandomStateTSequencer` to thread Scala `GameState` throughout (currently converts to proto internally). Then evaluate whether `RandomStateProtoSequencer` is still needed at all.
**Current State**: `RandomStateTSequencer` accepts Scala `GameState` via its `apply()` method but internally converts to proto. All callback methods (`withRandomActionResult`, `withActionResults`, etc.) pass `GameStateProto` to callers, forcing actions that use the sequencer to work with proto types internally.
**Target State**: Create a fully protoless sequencer where:
1. `lastState` returns Scala `GameState` (not `lastStateProto`)
2. All callback methods pass Scala `GameState` to callers
3. Actions using the sequencer can be fully protoless
**Migration Path**:
1. Add `lastState: GameState` method alongside `lastStateProto` (non-breaking)
2. Add parallel callback methods that pass Scala GameState (e.g., `withScalaActionResult`)
3. Migrate actions one by one to use the new Scala-based callbacks
4. Once all actions migrated, deprecate/remove proto-based callbacks
5. Remove `lastStateProto` once no longer used
**RandomStateSequencer Migration Progress** (PR #4679 introduced protoless `RandomStateSequencer`):
| Action | Status |
|--------|--------|
| `TruceTurnBackPhaseAction` | ✅ Migrated (PR #4680) |
| `EndHandleRiotsPhaseAction` | ✅ Migrated (PR #4684) |
| `PerformVassalCommandsPhaseAction` | ✅ Migrated |
| `PerformVassalDefenseDecisionsAction` | ✅ Migrated |
| `EndVassalCommandsPhaseAction` | ✅ Migrated |
| `PerformReconResolutionAction` | ✅ Migrated |
| `NewRoundAction` | ✅ Migrated (PR #4698) |
| `EndBattleAftermathPhaseAction` | ✅ Migrated (PR #4699) |
| `EndDiplomacyResolutionPhaseAction` | ✅ Migrated |
| `PerformUnaffiliatedHeroesAction` | ✅ Migrated |
| `EngineImpl.recursiveTransformT` | ✅ Migrated (PR #4704) |
| `ProtolessSequentialResultsActionWrapper` | ✅ Migrated (PR #4705) |
| `LegacyRandomStateTSequencer` | ✅ **Deleted** (PR #4705) |
**TCommandFactory Extraction** (PR #4684):
To enable lightweight mocking of command creation in tests, `TCommandFactory` trait was extracted from `CommandFactory`. This allows tests to mock just the `makeTCommand` method without pulling in all 40+ command dependencies that `CommandFactory` requires.
- `TCommandFactory` - lightweight trait with just `makeTCommand`
- `CommandFactory extends TCommandFactory` - maintains backward compatibility
- Actions accepting command factories now use `TCommandFactory` type for better testability
**Tier 4 - History APIs:**
```
src/main/scala/net/eagle0/eagle/service/InMemoryHistory.scala
src/main/scala/net/eagle0/eagle/service/PersistedHistory.scala
```
Change APIs to vend Scala `GameState` and `ActionResultT` instead of proto versions. `PersistedHistory` converts to proto internally for disk persistence; `InMemoryHistory` doesn't need proto at all.
### ActionResultProto Consumer Inventory
| File | Usage | Status |
|------|-------|--------|
| `ActionResultApplierImpl.scala` | Applies ActionResultT to Scala GameState | ✅ **Complete** |
| `ActionResultTApplierImpl.scala` | Legacy wrapper - converts to/from proto | Keep until all callers migrated |
| `RoundPhaseAdvancer.scala` | Uses Scala GameState | ✅ **Complete** |
| `RandomStateSequencer.scala` | Threads Scala GameState | ✅ **Complete** |
| `VigorXPApplier.scala` | Has both proto and Scala methods | Scala method exists, delete proto method when unused |
| `PerformForcedTurnBackAction.scala` | Fully protoless | ✅ **Complete** |
| `ResolveBattleAction.scala` | ✅ Fully protoless | Complete (PR #5048) |
| `InMemoryHistory.scala` | Stores proto results | Pending - vend Scala types |
| `PersistedHistory.scala` | Stores proto results | Pending - vend Scala types, convert for disk |
| `GameController.scala` | Uses proto for client communication | Keep proto (gRPC boundary) |
### Remaining Proto Usage in Actions
**Progress: 52 of 52 action files (100%) are fully protoless.**
All action files have been migrated to use Scala types:
| Action | Status | Notes |
|--------|--------|-------|
| `ResolveBattleAction` | ✅ Complete | PR #5048 - uses Scala GameState and ActionResultApplier |
| `PerformVassalCommandsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
| `EndHandleRiotsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
| `PerformVassalDefenseDecisionsAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
| `EndVassalCommandsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
**Note:** `NewRoundAction` is now fully protoless after converting `ChronicleEventGenerator` to return Scala `ChronicleEvent` types directly.
**Deleted Dead Code:**
- `UnaffiliatedHeroMovedAction` - Was never called; `PerformUnaffiliatedHeroesAction.heroMovedResult` constructs `ActionResultC` directly
- `HeroBackstoryUpdateActionGenerator.fromGameState` - Dead method that converted proto to Scala; only `apply(GameState)` is used
**Note**: `PerformReconResolutionAction` and `EndBattleAftermathPhaseAction` are now fully protoless after:
1. Migrating `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` to use Scala `ProvinceView`
2. Adding Scala overload of `ProvinceViewFilter.withdrawnFromProvinceView`
### Estimated Effort (Remaining)
| Component | Lines | Complexity | Blocks |
|-----------|-------|------------|--------|
| History API updates | ~100 | Low | - |
| **Total Remaining** | **~100** | | |
**Completed:**
-`ChronicleEventGenerator` converted to return Scala `ChronicleEvent` types directly
-`CommandChoiceHelpers` migrated to Scala types
-`ResolveBattleAction` refactored to use Scala GameState and ActionResultApplier (PR #5048)
### Enum Type Migrations
Proto enums are being converted to Scala sealed traits with converters at boundaries:
| Enum | Scala Type | Status | Notes |
|------|------------|--------|-------|
| `DiplomacyOfferStatus` | `Status` sealed trait | ✅ **Complete** | PR #5093 - `EligibleDiplomacyStatuses` uses Scala types internally |
| `RoundPhase` | `RoundPhase` sealed trait | Partial | Some usages converted |
| `BattalionType` | `BattalionType` sealed trait | Partial | Some usages converted |
**DiplomacyOfferStatus Migration (PR #5093):**
- `EligibleDiplomacyStatuses.scala` now returns `Vector[Status]` instead of `Vector[DiplomacyOfferStatus]`
- Call sites in `AvailableResolve*CommandFactory` files convert to proto via `StatusConverter.toProto` at the boundary
- This pattern should be applied to other proto enums
### CommandChoiceHelpers Migration Status
Several command selectors have already been converted to use Scala types:
| File | Status | Notes |
|------|--------|-------|
| `AttackCommandChooser.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
| `AlmsCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
| `FoodConsumptionUtils.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `RoundPhase` |
| `MarchSuppliesHelpers.scala` | ✅ **Protoless** | Uses `BattalionT` |
| `CombatUnitSelector.scala` | ✅ **Protoless** | Uses `HeroT`, `BattalionT`, `BattalionType` |
| `ExpandCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `FactionT` |
| `ImproveCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `HeroT` |
| `OrganizeCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `BattalionT`, `BattalionType` |
| `RansomOfferHelpers.scala` | ✅ **Protoless** | Uses Scala `GameState`, `FactionT` |
| `CommandChoiceHelpers.scala` | ✅ **Protoless** | Uses Scala `GameState` throughout |
| `ProvinceGoldSurplusCalculator.scala` | ✅ **Protoless** | Uses Scala types |
**All CommandChoiceHelpers selectors have been migrated to Scala types.**
### Progress Summary
| Metric | Value |
|--------|-------|
| Action files fully protoless | 52 / 52 (100%) ✅ |
| Proto usages in remaining actions | 0 |
| Next target | See "Next Candidates" section below |
### Next Candidates
Priority candidates for further deproto work:
1. **More Enum Migrations** - Apply the `DiplomacyOfferStatus` pattern to other proto enums:
- Files importing `net.eagle0.eagle.common.round_phase.RoundPhase` (proto) could use Scala `RoundPhase`
- Files importing `net.eagle0.eagle.common.battalion_type.BattalionType` (proto) could use Scala `BattalionType`
2. **AvailableCommandsFactory Files** - Many still use proto `GameState` internally:
- These files build proto `AvailableCommand` messages but could use Scala types for internal logic
- Convert to accept Scala `GameState`, only convert fields to proto when building the response
3. **History APIs** - `InMemoryHistory` and `PersistedHistory`:
- Change to vend Scala `GameState` and `ActionResultT` instead of proto versions
- `PersistedHistory` converts to proto internally for disk persistence
### Validation
- [x] `ActionResultApplier` created and tested
- [x] `RandomStateSequencer` threads Scala GameState throughout
- [x] `RoundPhaseAdvancer` uses T-types internally
- [x] `ProvinceViewFilter` has Scala overload for server-side use (PR #4752)
- [x] `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` use Scala `ProvinceView`
- [x] `ProvinceViewFilter.withdrawnFromProvinceView` has Scala overload
- [x] `CommandChoiceHelpers` uses Scala types ✅
- [x] All action files (52/52) are fully protoless ✅
- [ ] `ProvinceViewFilter` faction-filtered views use Scala types
- [ ] History APIs vend Scala types
- [ ] No `ActionResultProtoConverter.toProto()` calls except at persistence/gRPC boundaries
- [ ] All tests pass
---
## Phase 7: Clean Up Legacy Utilities
### Objective
Remove remaining direct proto imports from utility classes.
### Files to Modify
| File | Status |
|------|--------|
| `CommandChoiceHelpers.scala` | Accepts proto `GameState`; blocks full deproto of `PerformVassalCommandsPhaseAction` and `PerformVassalDefenseDecisionsAction` |
| `LegacyProvinceUtils.scala` | Replace with `ProvinceUtils.scala` - `hasImminentRiot` added (PR #4683) |
| `LegacyFactionUtils.scala` | Replace proto imports with `FactionT` |
| `LegacyUnaffiliatedHeroUtils.scala` | Replace proto imports with Scala models |
| `BattalionTypeLoader.scala` | Keep proto for file loading, convert immediately after |
| `BeastUtils.scala` | **Complete** - now uses Scala `BeastInfo` only |
### View Filters (Partially Complete)
The view filter utilities now have Scala overloads for server-side use:
| File | Status | Notes |
|------|--------|-------|
| `ProvinceViewFilter.scala` | **Partial** | `filteredProvinceView(ProvinceT, ScalaGameState)` added (PR #4752) |
| `ArmyFilter.scala` | **Partial** | `filterArmy(ScalaArmy, Map[BattalionId, BattalionT], Option[FactionId])` added |
| `BattalionViewFilter.scala` | **Complete** | Uses Scala `BattalionT` throughout |
| `GameStateViewFilter.scala` | Pending | Uses proto types throughout |
| `GameStateViewDiffer.scala` | Pending | Works with view protos |
**Unblocked Actions** (PR #4752):
- `EndBattleAftermathPhaseAction` - can now use `filteredProvinceView(province, scalaGameState)`
- `PerformReconResolutionAction` - can now use Scala overload
- `GameStateFactionExtensions` - can now use `updatedReconnedProvinces` with Scala types
**Remaining Work**:
- Faction-filtered `filteredProvinceView(Province, GameState, FactionId)` still uses proto types
- `withdrawnFromProvinceView` still uses proto types
- These are needed for client-facing views with visibility restrictions
---
## Phase 8: Verify Boundaries
### Objective
Confirm protos are used correctly at boundaries — and ONLY there.
### Expected Proto Usage (Keep)
- `EagleServiceImpl.scala` - gRPC boundary
- `InMemoryHistory.scala` / `PersistedHistory.scala` - Persistence boundary
- `*Converter.scala` - Explicit conversion utilities
- `*Loader.scala` - File loading utilities
### Expected No Proto Usage (Verify)
- `/library/actions/impl/` - Pure Scala models
- `/library/util/` - Pure Scala models (except loaders)
- `/model/state/` - Pure Scala models
---
## Open Questions
1. **Persistence Format**: Currently game state is persisted as proto. Should we keep proto for persistence (good for schema evolution) or switch to a different format?
2. **Shardok Integration**: `ResolveBattleAction` communicates with Shardok. Should the Shardok interface use protos (external service) or Scala models?
3. **View Generation**: `GameStateViewDiffer` works with view protos for client updates. Views need Scala models (`ProvinceViewT`, etc.) to allow actions like `EndBattleAftermathPhaseAction` to be fully protoless. The Scala views would be converted to proto only at the gRPC boundary when sending updates to clients.
---
## Success Criteria
### Code Quality
- [ ] Zero proto imports in `/library/actions/` (except boundaries)
- [ ] Zero proto imports in `/library/` utilities (except loaders)
- [ ] `GameStateT` used throughout engine internals
- [ ] Proto usage limited to: `EagleServiceImpl`, loaders, converters, persistence
### Architecture
- [ ] Clear separation: Scala models (internal) vs Proto (boundaries)
- [ ] Converters as the only bridge between domains
- [ ] No "proto creep" into business logic
-95
View File
@@ -1,95 +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 3.1 Flash-Lite** is recommended as the default:
- Priced at $0.25/$1.50 per 1M input/output tokens
- Significantly faster than 2.5 Flash-Lite on throughput and TTFT
- Meaningfully smarter than 2.5 Flash-Lite, while remaining one of the cheapest options
- Quality is more than sufficient for short narrative snippets
### Alternative Options
| Priority | Model | When to Use |
|----------|-------|-------------|
| Speed + Cost | Gemini 3.1 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-3.1-flash-lite-preview)
- `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)
-103
View File
@@ -1,103 +0,0 @@
# New Profession Proposals
This document proposes 5 new hero professions for Eagle0. Each profession has abilities for both the Eagle (strategic) and Shardok (tactical) game layers.
## Current Professions Reference
| Profession | Eagle Ability | Shardok Abilities |
|------------|---------------|-------------------|
| **Mage** | Control Weather | Lightning Bolt, Meteor, Freeze Water, Start Fire (enhanced) |
| **Necromancer** | Start Epidemic | Raise Dead, Fear |
| **Engineer** | (general) | Repair, Fortify, Build Bridge, Reduce (siege) |
| **Paladin** | Alms (prioritized) | Holy Wave |
| **Ranger** | Recon | Scout, Hide (enhanced), Brave Water (enhanced) |
| **Champion** | (general) | Challenge Duel |
---
## Proposed New Professions
### 1. HERALD (Morale & Communication Specialist)
**Fantasy:** The inspiring leader who rallies troops and carries messages across the battlefield.
**Eagle Ability: "Rally Province"**
- Spend vigor to boost recruitment in a province for one turn, or reduce unrest
- Synergy: Pairs well with provinces in turmoil or after losses
**Shardok Ability: "Inspire"**
- Target friendly unit within 3 hexes gains +1 action point this turn
- Creates interesting tactical choices about when to act vs. when to buff allies
- Cannot target self (prevents simple optimization)
---
### 2. ALCHEMIST (Fire & Transformation Specialist)
**Fantasy:** The mad scientist who manipulates the elements through science, not magic.
**Eagle Ability: "Transmute"**
- Convert one resource type to another in a province (gold to food or vice versa, at a loss)
- Provides economic flexibility during shortages
**Shardok Ability: "Wildfire"**
- Start a fire that spreads to 2 additional adjacent hexes immediately (not just at end of round)
- More aggressive than Mage's Start Fire but less controlled
- Cannot freeze water (that's magic, not science)
---
### 3. WARDEN (Defensive Specialist)
**Fantasy:** The stalwart defender who holds the line and protects allies.
**Eagle Ability: "Garrison"**
- A province with a Warden-led unit gets +1 to defense when attacked
- Encourages strategic placement of defensive heroes
**Shardok Ability: "Intercept"**
- Once per turn, when an adjacent friendly unit is attacked, the Warden's unit can take the hit instead
- Creates a "bodyguard" mechanic that protects valuable units
- Costs action points to maintain readiness
---
### 4. INQUISITOR (Anti-Magic & Intelligence)
**Fantasy:** The witch-hunter who counters supernatural threats and uncovers secrets.
**Eagle Ability: "Expose"**
- Reveal hidden information about enemy heroes in a province (stats, profession, vigor)
- Counter to Ranger's stealth/recon advantages
**Shardok Ability: "Dispel"**
- Cancel an active magical effect: stop a meteor cast, remove Fear from friendly unit, or reveal a hidden unit
- Direct counter to Mage and Necromancer abilities
- Creates meaningful profession rock-paper-scissors
---
### 5. BEASTMASTER (Animal Control)
**Fantasy:** The wild one who commands beasts and understands nature's fury.
**Eagle Ability: "Suppress Beasts" (Enhanced)**
- Already exists in game, but Beastmaster does it at reduced vigor cost
- Additionally: Can redirect beast attacks to enemy provinces instead of just suppressing
**Shardok Ability: "Beast Call"**
- Summon a wolf pack (weak undead-style unit) that attacks the nearest enemy
- Wolves act immediately but disappear at end of round
- Provides disposable units for screening or harassing archers
---
## Design Philosophy
These professions were designed to:
1. **Fill mechanical gaps**: Warden provides defensive depth, Inquisitor counters magic-heavy strategies
2. **Create counterplay**: Inquisitor vs Mage/Necromancer, Beastmaster vs Rangers (nature vs nature)
3. **Avoid overlap**: Each has a unique niche not covered by existing professions
4. **Support both layers**: Each ability is meaningful in its respective game mode
5. **Enable interesting decisions**: Intercept creates bodyguard tactics, Inspire creates action economy choices
-150
View File
@@ -1,150 +0,0 @@
# Notification Diff Batching Optimization
## Problem
`ActionResultFilter.filterForPlayer` generates per-action-result game state diffs, which is expensive. Profiling shows significant time spent in `filteredGameStateDiff``GameStateViewFilter.filteredGameState` (called twice per result) → `GameStateViewDiffer.diff`.
A potential optimization is to batch diffs: instead of computing N diffs for N action results, compute one combined diff representing the final state change. However, this is blocked by how client notification generators work.
## Current Architecture
### Server Side
1. `ActionResultFilter.filterForPlayer` processes each action result
2. For each result, computes `filteredGameStateDiff(before, after, factionId)`
3. Returns `Vector[ActionResultView]` where each view has its own `gameStateDiff`
### Client Side
1. `EagleGameModel.HandleNewHistoryEntry` processes each `ActionResultView`:
```csharp
private void HandleNewHistoryEntry(ActionResultView arv) {
_currentModel.HistoryCount++;
MaybeSendNotification(arv); // Uses currentModel state
ApplyGameStateViewDiff(arv.GameStateDiff); // Updates currentModel
}
```
2. Notification generators receive `(ActionResultView, IGameModel)` and look up display data from the model:
```csharp
var province = currentModel.Provinces[details.ProvinceId];
var factionName = currentModel.FactionName(details.FactionId);
var hero = currentModel.Heroes[heroId];
var affectedProvinces = currentModel.ProvincesForFaction(factionId);
```
### The Problem
Notification for action N sees model state after actions 1..N-1 have been applied. If we batch diffs, notification N would see model state before ANY actions, potentially showing stale data.
Example:
1. Action 1: Province X conquered by Faction B (was Faction A)
2. Action 2: Notification needs to show Province X's current ruler
With batching, action 2's notification would incorrectly show Faction A.
## Audit of Client Notification Generators
~50 generators access `currentModel`. Key lookup patterns:
| Lookup | Count | Mutable? | Risk |
|--------|-------|----------|------|
| `currentModel.PlayerId` | 22 | No | Safe |
| `currentModel.Provinces[id]` | 17 | Yes | `RulingFactionId` changes on conquest |
| `currentModel.Heroes[id]` | ~20 | Mostly safe | Hero data stable, used for display |
| `currentModel.FactionName(id)` | ~10 | No | Names don't change |
| `currentModel.MaybeDestroyedFaction(id)` | ~15 | Yes | Faction may be destroyed |
| `currentModel.ProvincesForFaction(id)` | ~15 | Yes | Changes on conquest |
### State-Changing Action Types
- `ProvinceConquered` - changes province ownership
- `FactionDestroyed` - removes faction
- `FactionLeaderRemoved` - changes faction head
## Proposed Solution: Server-Side Display Data
Eliminate client model lookups by including all display data in server-generated notifications.
### Current Notification Structure
```scala
case class NotificationC(
details: NotificationDetails,
targetFactionIds: Vector[FactionId],
affectedProvinceIds: Vector[ProvinceId], // Already exists, underused
affectedHeroIds: Vector[HeroId], // Already exists, underused
llm: NotificationT.Llm,
deferred: Boolean
)
```
### Proposed Changes
**Option A: Add fields to NotificationC**
```scala
case class NotificationC(
details: NotificationDetails,
targetFactionIds: Vector[FactionId],
affectedProvinceIds: Vector[ProvinceId],
affectedHeroIds: Vector[HeroId],
// New fields:
factionNames: Map[FactionId, String],
displayedHeroViews: Vector[HeroView],
provinceNames: Map[ProvinceId, String],
llm: NotificationT.Llm,
deferred: Boolean
)
```
**Option B: Enrich each NotificationDetails type**
```scala
case class TruceAccepted(
offeringFactionId: FactionId,
offeringFactionName: String, // New
targetFactionId: FactionId,
targetFactionName: String, // New
ambassadorHeroId: HeroId,
ambassadorHeroView: HeroView // New
)
```
### Implementation Steps
1. **Proto changes**: Add new fields to `Notification` message
2. **Server**: Populate display fields when creating notifications
3. **Client**: Update ~50 generators to use notification fields
4. **Test**: Add test that greps for `currentModel.` access in generators (excluding `PlayerId`)
5. **Server optimization**: With client decoupled from model state, batch diffs in `filterForPlayer`
### Trade-offs
**Pros:**
- Clean separation: server provides all display data
- Enables diff batching optimization
- Easier to reason about notification correctness
- Test can enforce the invariant
**Cons:**
- Larger notification messages (includes names, hero views)
- Proto changes required
- ~50 generators need updating (mechanical but tedious)
- Server must know what display data each notification type needs
## Alternative Approaches Considered
### A: Selective Per-Action Diffs
Only generate individual diffs for action results with notifications that need mutable model state. Requires tracking which notification types need which state.
**Rejected because:** Fragile; easy to add a new generator that breaks the invariant.
### B: State-Change-Triggered Diffs
If batch contains state-changing action types (ProvinceConquered, etc.), generate individual diffs from that point. Otherwise batch.
**Rejected because:** Still conservative; many batches would fall back to individual diffs.
## Status
**Deferred** - Current performance is acceptable. This doc captures the analysis for future reference if optimization becomes necessary.
## References
- `ActionResultFilter.scala` - Server-side filtering
- `EagleGameModel.cs` - Client-side model updates
- `Assets/Eagle/Notifications/` - All notification generators
- `NotificationT.scala` - Server notification types
+383
View File
@@ -0,0 +1,383 @@
# Plan: Extract OAuth to Go Service
## Goal
Move OAuth authentication handling from the Eagle Scala server into a separate Go service. This simplifies Eagle (gRPC-only, no HTTP), reduces complexity, and sets up for potentially moving JWT validation outside Eagle too.
## Architecture Decision: Sidecar Service (Not DO Functions)
**Recommendation: Go sidecar service on the same droplet, in a separate container**
**Why not DO Functions:**
- OAuth requires **stateful sessions** (pendingOAuth/completedOAuth maps with 10-min TTL)
- Client polling pattern (every 2 seconds) would incur high function invocation costs
- Cold start latency problematic for auth flows
- State would require external store (Redis), adding complexity
**Why sidecar (separate container):**
- Simple process on same droplet, minimal network latency
- In-memory state management (like current Scala impl)
- Easy to monitor/debug alongside Eagle
- Can share filesystem for key files (RSA keys) via volume mounts
- **Independent deployment**: Deploying Eagle doesn't restart auth service (and vice versa)
- **Independent scaling**: Could move to separate droplet later if needed
## Current Architecture (What Exists)
```
Unity Client
├── GetOAuthUrl RPC → Eagle AuthServiceImpl → OAuthService.getAuthUrl()
├── [User browser auth] → HTTP callback → OAuthHttpHandler → OAuthService.handleCallback()
├── CheckOAuthStatus RPC (polling) → AuthServiceImpl → OAuthService.checkStatus()
└── All other RPCs include JWT → AuthorizationInterceptor validates
```
**Key files:**
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth flow, state management
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - JWT creation/validation
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD (persisted)
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC Auth service
- `src/main/scala/net/eagle0/eagle/service/OAuthHttpHandler.scala` - HTTP callback handler
## Target Architecture (Phase 1)
```
Unity Client
├── GetOAuthUrl RPC ──────────────┐
├── CheckOAuthStatus RPC (polling)├──→ Eagle (port 40032) ──proxy──→ Go Auth Container (port 40033)
├── RefreshToken RPC ─────────────┘ │
├── [User browser] → HTTP callback ────────────────────────────────────────┤
│ ↓
│ (Internal gRPC: GetOrCreateUser, GetUser)
│ ↓
└── Game RPCs with JWT ─────────────────────→ Eagle (port 40032) ← JWT validation stays here
[Same Droplet]
┌─────────────────────────────────────────────────────────────────────────────┐
│ │
│ ┌──────────────────────┐ ┌────────────────────────────────────┐ │
│ │ Go Auth Container │◄────────►│ Eagle Container │ │
│ │ (eagle0-auth) │ internal │ (eagle0-server) │ │
│ │ │ gRPC │ │ │
│ │ - OAuth flow │ │ - JWT validation │ │
│ │ - JWT creation │ │ - UserService (persistence) │ │
│ │ - HTTP callback │ │ - Game logic │ │
│ └──────────────────────┘ └────────────────────────────────────┘ │
│ │ │ │
│ └────────────────┬───────────────────────┘ │
│ ▼ │
│ /etc/eagle0/keys/ (shared volume) │
│ - private.pem │
│ - public.pem │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Component Responsibilities
### Go Auth Service (NEW - separate container)
- **OAuth flow**: getAuthUrl, handleCallback (HTTP), checkStatus
- **State management**: pendingOAuth, completedOAuth maps with TTL
- **JWT creation**: Issue access/refresh tokens (shares RSA private key with Eagle)
- **Token refresh**: Validate refresh token, issue new access token
- Calls Eagle's internal UserService gRPC to find/create users
### Eagle Server (SIMPLIFIED)
- **JWT validation**: AuthorizationInterceptor stays (validates tokens on game RPCs)
- **UserService**: Stays in Eagle (user persistence, display name logic)
- **New internal gRPC**: Expose GetOrCreateUser, GetUser for Go service to call
- **Proxy (Phase 1)**: Forward OAuth RPCs to Go service
- **Remove (Phase 2)**: OAuthService, OAuthHttpHandler, HTTP server setup
### Unity Client (NO CHANGES in Phase 1)
- Eagle proxies Auth RPCs to Go service
- Client still connects to Eagle on port 40032
## Implementation Phases
### Phase 1: Go Auth Service with Eagle Proxy (Zero Client Changes)
1. **Create Go service structure**
```
src/main/go/net/eagle0/authservice/
├── main.go # Entry point, starts gRPC + HTTP servers
├── oauth.go # OAuth state management, provider configs
├── jwt.go # JWT creation (copy logic from Scala)
├── handlers.go # gRPC handlers for Auth service
├── http_callback.go # HTTP handler for OAuth callback
└── BUILD.bazel
```
2. **Internal gRPC proto for Eagle UserService**
```protobuf
// src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto
service InternalUserService {
rpc GetOrCreateUser(GetOrCreateUserRequest) returns (GetOrCreateUserResponse);
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}
message GetOrCreateUserRequest {
string provider = 1; // "discord" or "google"
string provider_user_id = 2;
string email = 3;
string avatar_url = 4;
}
message GetOrCreateUserResponse {
string user_id = 1;
string display_name = 2;
string avatar_url = 3;
bool is_admin = 4;
bool is_new_user = 5;
}
```
3. **Eagle: Expose InternalUserService**
- New `InternalUserServiceImpl.scala` wrapping UserService
- Bind to same port, different service name (internal only)
4. **Eagle: Proxy Auth RPCs to Go**
- AuthServiceImpl delegates GetOAuthUrl, CheckOAuthStatus, RefreshToken to Go service
- SetDisplayName, GetCurrentUser, Logout stay in Eagle
5. **Share RSA keys via volume mount**
- Go service reads same key files as Eagle
- Both can create valid JWTs
- Eagle continues to validate JWTs
6. **Docker/Container setup**
- New Dockerfile for Go auth service
- docker-compose or Kubernetes config for both containers
- Shared volume for /etc/eagle0/keys/
- Internal network for container-to-container gRPC
### Phase 2: Client Direct to Go Service (Future)
1. **Update Unity client**
- Connect to Go Auth service directly for OAuth RPCs
- Keep connecting to Eagle for game RPCs
2. **Remove Eagle proxy code**
- Delete AuthServiceImpl OAuth delegation
- AuthServiceImpl only handles SetDisplayName, GetCurrentUser, Logout
### Phase 3: Move JWT Validation to Go (Optional Future)
1. **Go service validates JWTs**
- Add ValidateToken RPC or use shared middleware pattern
2. **Eagle calls Go for validation**
- AuthorizationInterceptor calls Go to validate tokens
- OR: Use stateless validation (both share public key)
## Files to Create
### Go Service
- `src/main/go/net/eagle0/authservice/main.go`
- `src/main/go/net/eagle0/authservice/oauth.go`
- `src/main/go/net/eagle0/authservice/jwt.go`
- `src/main/go/net/eagle0/authservice/handlers.go`
- `src/main/go/net/eagle0/authservice/http_callback.go`
- `src/main/go/net/eagle0/authservice/BUILD.bazel`
### Protos
- `src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto`
### Scala
- `src/main/scala/net/eagle0/eagle/service/InternalUserServiceImpl.scala`
### Docker/Deployment
- `ci/auth_service.Dockerfile`
- Update `docker-compose.yml` (or equivalent)
## Files to Modify
### Scala (Phase 1)
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - Proxy OAuth RPCs to Go
- `src/main/scala/net/eagle0/eagle/Main.scala` - Start internal user service, add auth-service-url flag
### Scala (Phase 2 - Removal)
- Delete `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala`
- Delete `src/main/scala/net/eagle0/eagle/service/OAuthHttpHandler.scala`
- Simplify `src/main/scala/net/eagle0/eagle/Main.scala` - Remove HTTP server
### Unity (Phase 2)
- `Assets/Auth/OAuthManager.cs` - Point OAuth RPCs to Go service port
- `Assets/EagleConnection.cs` - Add second channel for auth service
## Key Implementation Details
### State Management in Go
```go
type OAuthState struct {
Provider string
CreatedAt time.Time
}
type OAuthResult struct {
Success bool
UserInfo *ProviderUserInfo
Provider string
Error string
}
var pendingOAuth = sync.Map{} // state -> OAuthState
var completedOAuth = sync.Map{} // state -> OAuthResult
const stateExpiration = 10 * time.Minute
// Background goroutine cleans expired states every minute
func cleanupExpiredStates() {
ticker := time.NewTicker(1 * time.Minute)
for range ticker.C {
cutoff := time.Now().Add(-stateExpiration)
pendingOAuth.Range(func(key, value any) bool {
if value.(OAuthState).CreatedAt.Before(cutoff) {
pendingOAuth.Delete(key)
}
return true
})
// Similar for completedOAuth
}
}
```
### JWT Creation in Go
```go
import "github.com/golang-jwt/jwt/v5"
type EagleClaims struct {
jwt.RegisteredClaims
UserId string `json:"userId"`
DisplayName string `json:"displayName"`
IsAdmin bool `json:"isAdmin"`
}
func CreateAccessToken(userId, displayName string, isAdmin bool) (string, error) {
claims := EagleClaims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
UserId: userId,
DisplayName: displayName,
IsAdmin: isAdmin,
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
return token.SignedString(privateKey)
}
```
### OAuth Provider Configs
- Read from environment variables (same as current OAuthConfig.scala)
- DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET
- GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
- OAUTH_CALLBACK_URL (e.g., https://eagle0.shardok.games/oauth/callback)
### Container Networking
```yaml
# docker-compose.yml example
services:
eagle0-auth:
build:
context: .
dockerfile: ci/auth_service.Dockerfile
ports:
- "40033:40033" # gRPC
- "8080:8080" # HTTP callback
volumes:
- ./keys:/etc/eagle0/keys:ro
environment:
- DISCORD_CLIENT_ID
- DISCORD_CLIENT_SECRET
- GOOGLE_CLIENT_ID
- GOOGLE_CLIENT_SECRET
- EAGLE_INTERNAL_URL=eagle0-server:40034
eagle0-server:
build:
context: .
dockerfile: ci/eagle_run.Dockerfile
ports:
- "40032:40032" # Public gRPC
expose:
- "40034" # Internal gRPC (container-to-container only)
volumes:
- ./keys:/etc/eagle0/keys:ro
- ./data:/var/lib/eagle0
environment:
- AUTH_SERVICE_URL=eagle0-auth:40033
```
## Deployment
### Development
```bash
# Terminal 1: Go Auth Service
bazel run //src/main/go/net/eagle0/authservice:authservice -- \
--grpc-port=40033 \
--http-port=8080 \
--eagle-internal-url=localhost:40034
# Terminal 2: Eagle Server
bazel run //src/main/scala/net/eagle0/eagle:eagle_server -- \
--eagle-grpc-port=40032 \
--internal-grpc-port=40034 \
--auth-service-url=localhost:40033
```
### Production
- Both containers on same droplet via docker-compose
- Shared volume for RSA keys at /etc/eagle0/keys/
- Internal Docker network for container-to-container communication
- External access: 40032 (Eagle gRPC), 8080 (OAuth HTTP callback)
## Testing Strategy
1. **Unit tests for Go service**
- OAuth state management (expiration, cleanup)
- JWT creation matches Scala output (test with same keys)
- HTTP callback parsing
2. **Integration tests**
- Go service ↔ Eagle internal gRPC
- Full OAuth flow with mock provider
3. **Existing tests continue to pass**
- All Scala tests (JWT validation, user service)
4. **End-to-end test**
- Spin up both containers
- Run OAuth flow through proxy
## Risks and Mitigations
| Risk | Mitigation |
|------|------------|
| Key file permissions | Shared volume with read-only mount |
| State loss on Go restart | Document this (same as current Scala behavior); consider Redis later |
| Clock skew affecting JWT | Both on same machine |
| OAuth callback race | HTTP callback completes before gRPC poll |
| Container networking | Use docker-compose for reliable internal DNS |
| Proxy adds latency | Minimal (same machine), remove in Phase 2 |
## Estimated Scope
- **Phase 1**: ~500-700 lines Go, ~100 lines Scala changes, ~50 lines Docker config
- **Phase 2**: ~50 lines Unity, deletion of ~300 lines Scala
- **Phase 3**: Optional, separate decision
## Alternative Considered: Move Everything to Go
Could move UserService to Go as well, but:
- UserService is tightly integrated with game persistence
- Would require duplicating persistence layer
- Not worth the complexity for now
Keep UserService in Eagle, expose via internal gRPC.
## Open Questions
1. **HTTP callback routing**: Does the OAuth callback URL need to change, or can we route traffic from the existing URL to the new Go service?
2. **Health checks**: Should we add health check endpoints for container orchestration?
3. **Logging**: Should Go service log to same format/destination as Eagle?
+189
View File
@@ -0,0 +1,189 @@
# Discord + Google OAuth Implementation Plan
## Overview
Replace HTTP Basic Auth with OAuth 2.0 (Discord + Google) for Eagle0. Users authenticate via system browser, receive JWT tokens, and choose their own display names.
## Architecture
```
Unity Client Eagle Server
| |
| 1. Click "Login with Discord/Google" |
| -------------------------------------------------> |
| GetOAuthUrl(provider) -> auth_url + state |
| |
| 2. Open system browser -> OAuth consent |
| 3. User authenticates with provider |
| 4. Redirect to eagle0://auth/callback?code=xxx |
| |
| 5. ExchangeCode(code, state) |
| -------------------------------------------------> |
| Exchange code with provider |
| Fetch user info (id, email, avatar) |
| Create/update user record |
| Issue JWT + refresh token |
| <------------------------------------------------- |
| (jwt, refresh_token, user_info, is_new_user) |
| |
| 6. [If new user] SetDisplayName(name) |
| -------------------------------------------------> |
| |
| 7. Subsequent gRPC calls |
| Authorization: Bearer <jwt> |
| -------------------------------------------------> |
```
## Key Design Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| OAuth flow | System browser + deep link | Secure, supports password managers |
| Code exchange | Eagle server directly | No separate auth service needed |
| JWT signing | RS256 (asymmetric) | Future flexibility for token verification |
| User storage | Protobuf file via Persister | Consistent with existing patterns |
| Token expiry | 7-day access, 30-day refresh | Balance security and gaming UX |
## Implementation Phases
### Phase 1: Proto Definitions & Infrastructure
**New files:**
- `src/main/protobuf/net/eagle0/eagle/api/auth.proto` - Auth API messages
- `src/main/protobuf/net/eagle0/eagle/internal/user.proto` - User storage schema
**Key proto messages:**
```protobuf
// API
GetOAuthUrlRequest/Response // Get OAuth URL to open in browser
ExchangeCodeRequest/Response // Exchange auth code for JWT
SetDisplayNameRequest/Response // Set user's display name
RefreshTokenRequest/Response // Refresh expired access token
// Internal storage
User // user_id, display_name, oauth_identities
UserDatabase // All users + indexes for lookup
```
### Phase 2: Eagle Server Auth Services
**New Scala files:**
- `src/main/scala/net/eagle0/eagle/auth/OAuthConfig.scala` - Discord/Google config from env vars
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - JWT creation/validation (RS256)
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD, display name validation
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth code exchange
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC service implementation
**Modify:**
- `src/main/scala/net/eagle0/eagle/service/AuthorizationInterceptor.scala`
- Replace Basic Auth parsing with JWT validation
- Skip auth for public endpoints (GetOAuthUrl, ExchangeCode, RefreshToken)
- `src/main/scala/net/eagle0/eagle/service/AuthorizationUtils.scala`
- Change context keys from `userName` to `userId` + `displayName`
- `src/main/scala/net/eagle0/eagle/service/Main.scala`
- Wire up new auth services and JWT key loading
### Phase 3: Unity Client OAuth Flow
**New C# files:**
- `Assets/Auth/OAuthManager.cs` - OAuth flow + deep link handling
- `Assets/Auth/TokenStorage.cs` - Secure token persistence
- `Assets/Auth/AuthClient.cs` - gRPC client for auth service
**Modify:**
- `Assets/EagleConnection.cs`
- Replace `AuthInterceptor` (Basic Auth) with `JwtAuthInterceptor` (Bearer token)
- `Assets/ConnectionHandler/ConnectionHandler.cs`
- Replace username/password UI with Discord/Google login buttons
- Add display name setup flow for new users
### Phase 4: Platform Configuration
**Deep link registration:**
- iOS: Add `eagle0://` to CFBundleURLSchemes in Info.plist
- Android: Add intent-filter for `eagle0://auth` in AndroidManifest.xml
- Desktop: Register URL scheme (Windows registry / macOS plist)
**OAuth provider setup:**
1. Discord Developer Portal: Create app, add redirect URI `eagle0://auth/callback`
2. Google Cloud Console: Create OAuth client, add redirect URI
**Environment variables (server):**
```
DISCORD_CLIENT_ID
DISCORD_CLIENT_SECRET
GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET
JWT_PRIVATE_KEY_PATH
JWT_PUBLIC_KEY_PATH
```
### Phase 5: Testing
**Unit tests:**
- `JwtServiceSpec.scala` - Token creation/validation
- `UserServiceSpec.scala` - Display name validation, uniqueness
- `OAuthServiceSpec.scala` - OAuth flow with mocked providers
**Integration tests:**
- Full OAuth flow with mock provider
- JWT validation in AuthorizationInterceptor
- gRPC calls with valid/invalid tokens
**Manual testing:**
- [ ] Discord login (Windows, macOS)
- [ ] Google login (Windows, macOS)
- [ ] Deep link callback works
- [ ] Display name validation
- [ ] Session persistence across restarts
- [ ] Token refresh
## Files Summary
### Create
| File | Purpose |
|------|---------|
| `src/main/protobuf/net/eagle0/eagle/api/auth.proto` | Auth API definitions |
| `src/main/protobuf/net/eagle0/eagle/internal/user.proto` | User storage schema |
| `src/main/scala/net/eagle0/eagle/auth/OAuthConfig.scala` | Provider config |
| `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` | JWT handling |
| `src/main/scala/net/eagle0/eagle/auth/UserService.scala` | User management |
| `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` | OAuth flow |
| `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` | gRPC service |
| `Assets/Auth/OAuthManager.cs` | Unity OAuth manager |
| `Assets/Auth/TokenStorage.cs` | Token storage |
| `Assets/Auth/AuthClient.cs` | Auth gRPC client |
### Modify
| File | Changes |
|------|---------|
| `AuthorizationInterceptor.scala` | Basic Auth -> JWT validation |
| `AuthorizationUtils.scala` | userName -> userId + displayName |
| `Main.scala` | Wire auth services |
| `EagleConnection.cs` | AuthInterceptor -> JwtAuthInterceptor |
| `ConnectionHandler.cs` | Login UI -> OAuth buttons + display name |
### Delete
- nginx htpasswd configuration (no longer needed)
## Security Considerations
1. **State parameter** - CSRF protection in OAuth flow
2. **PKCE** - Consider adding for mobile (enhancement)
3. **Secure storage** - Use Keychain (iOS) / Keystore (Android) for tokens
4. **Token refresh** - 7-day access tokens with 30-day refresh
5. **Rate limiting** - Limit login attempts per IP
## Dependencies to Add
**Scala (MODULE.bazel):**
- JWT library (e.g., `jwt-scala` or `nimbus-jose-jwt`)
- HTTP client (e.g., `sttp` for OAuth requests)
**Unity:**
- Deep linking is built-in (Unity 2021+)
- No additional packages required
## Rollback Plan
Keep Basic Auth code in a feature branch. Both auth methods can coexist during transition via feature flag if needed.
+350
View File
@@ -0,0 +1,350 @@
# OAuth Implementation: Next Steps and Design
## Executive Summary
The OAuth implementation is functional but has several gaps that need addressing before it's production-ready. This document outlines the known issues, proposes a comprehensive user identity model, and provides a prioritized implementation plan.
## Current State (Updated January 2026)
### What Works ✅
- Discord OAuth flow (server-mediated polling)
- Google OAuth flow
- JWT token generation and validation
- User creation and display name setting
- Auto-login with stored tokens
- Basic game creation and play with OAuth users
- Headshot fetching via public CDN (no auth required)
- Logout button in lobby (preserves tokens for quick reconnect)
- Environment (prod/qa) and user display in lobby
- Game identity with userName = displayName (PR #4964 merged)
### Known Issues
#### 1. Game Identity Model Fragility (Deferred)
**Status**: Accepted for now. PR #4964 merged with `userName = displayName`.
**Current behavior**:
- Games store `userNameToFactionId: Map[String, Int]`
- For JWT users, this maps displayName → factionId
- displayName is technically mutable (users could change it)
- No migration path when displayName changes
**Why this is acceptable**:
1. We don't currently have a "change display name" feature
2. The alternative (using userId) requires more extensive changes
3. Can migrate to userId-based identity later if needed
#### 2. In-Game Headshot Fetching ✅ FIXED
**Solution**: Made the `eagle0-headshots` S3 bucket public and enabled CDN.
- Client now fetches directly from `https://eagle0-headshots.sfo3.cdn.digitaloceanspaces.com/`
- No authentication required
- Works for both OAuth and Basic Auth users
- Simpler architecture, no dependency on home Mac server
#### 3. Logout from Lobby ✅ FIXED
**Solution**: Added logout button to lobby UI (PR #4967).
- Button disconnects from server and returns to connection screen
- Intentionally does NOT clear OAuth tokens
- Allows quick reconnect with same account without full OAuth flow
#### 4. Display Name Uniqueness Not Enforced (Medium) - OPEN
**Problem**: User was able to set displayName "nolen" when that name was already taken.
**Root cause**: Unknown - needs investigation. Either:
- The uniqueness check is buggy
- The displayNameIndex wasn't populated correctly during user creation
- Race condition during concurrent registrations
#### 5. Admin Server Crashes ✅ FIXED
**Solution**: PR #4964 sets `userName = displayName` for JWT users.
#### 6. Intermittent "Expired" Errors During Login (Medium) - INVESTIGATING
**Problem**: Users occasionally get "OAuth session expired" errors even when server logs show the callback succeeded.
**Status**: Added diagnostic logging in PR #4974 to trace:
- State creation in `getAuthUrl`
- State lookup in `handleCallback`
- Result lookup in `checkStatus`
**Possible causes**:
- State mismatch between client and server
- Race condition in polling
- Cleanup running at wrong time
#### 7. Token Expiry Field Bug ✅ FIXED
**Problem**: `CheckOAuthStatusResponse.expiresAt` was returning refresh token expiry (30 days) instead of access token expiry (7 days).
**Solution**: Fixed in PR #4974 to calculate correct access token expiry.
---
## Proposed User Identity Model
### Design Principles
1. **Stable Internal Identity**: `userId` (UUID) is the only key used for persistent associations
2. **Display Name is Cosmetic**: Can change without breaking game associations
3. **Backwards Compatibility**: Basic Auth continues to work for local development
4. **Multi-Provider Support**: Users can link Discord, Google, and future providers
5. **Avatar Flexibility**: Use OAuth avatar by default, support custom uploads later
### Data Model
```
User {
userId: String (UUID) // Primary key, immutable, used for all internal references
displayName: String // Unique, user-visible, mutable with migration
displayNameLower: String // Case-insensitive uniqueness
email: String // Primary email for account recovery/linking
avatarUrl: String // Current avatar URL
avatarData: bytes // Cached avatar for offline/fast access (future)
oauthIdentities: [OAuthIdentity]
createdAt: Timestamp
lastLoginAt: Timestamp
isAdmin: Boolean
}
OAuthIdentity {
provider: String // "discord", "google", etc.
providerUserId: String // Provider's user ID
providerEmail: String // Email from this provider
avatarUrl: String // Avatar from this provider
linkedAt: Timestamp
}
```
### Identity Resolution Strategy
The key question: **What should `AuthorizationUtils.userName` return?**
#### Option A: userName = displayName (Current PR #4964)
- **Pro**: Human-readable in logs, game saves, debugging
- **Con**: Breaks if displayName changes
- **Migration**: None needed now, complex later
#### Option B: userName = userId (Recommended)
- **Pro**: Stable identity, displayName changes are safe
- **Con**: UUIDs in logs are ugly, need display name lookup for UI
- **Migration**: Cleaner long-term, but breaking change for any existing OAuth games
#### Option C: Hybrid with Migration Support
- **userName** = userId for new games
- **Legacy lookup** for old games by displayName
- **Display layer** resolves userId → displayName for UI
**Recommendation**: Option B with a display name resolution layer. The ugliness in logs is acceptable for the stability it provides. Implement a `UserService.resolveDisplayName(identifier: String): String` that returns displayName for UUIDs or the identifier itself for legacy usernames.
### Account Linking Strategy
#### Automatic Linking (Future)
When a user logs in with a new OAuth provider:
1. Check if the provider email matches an existing user's email
2. If match found, prompt: "An account exists with this email. Link accounts?"
3. If confirmed, add new OAuthIdentity to existing user
4. If declined, create separate account (different email required)
#### Manual Linking (MVP)
1. User logs in with primary account
2. User goes to Settings → Linked Accounts
3. User clicks "Link Discord" or "Link Google"
4. OAuth flow adds new identity to current user
### Avatar/Headshot Strategy
#### Phase 1: OAuth Avatars (MVP)
- Store `avatarUrl` from OAuth provider during login
- Server proxies avatar requests to avoid CORS issues
- Cache avatars locally with TTL
#### Phase 2: Avatar Caching
- Download avatar to local storage on login
- Serve from local storage for reliability
- Refresh periodically or on login
#### Phase 3: Custom Avatars (Future)
- Allow users to upload custom avatar
- Store in S3/DO Spaces
- Custom avatar overrides OAuth avatar
---
## Implementation Plan
### Phase 1: Stabilization ✅ COMPLETE
#### 1.1 Fix Display Name Uniqueness Bug - OPEN
- [ ] Investigate why "nolen" was allowed when it existed
- [ ] Add logging to `setDisplayName` to trace the issue
- [ ] Ensure `displayNameIndex` is correctly maintained
- [ ] Add unit tests for uniqueness enforcement
#### 1.2 Add Logout Button to Lobby ✅ DONE
- [x] Add "Logout" button to lobby UI
- [x] Disconnect from server
- [x] Navigate to connection screen
- [x] Preserve OAuth tokens for quick reconnect (intentional change from original plan)
#### 1.3 Merge PR #4964 (userName = displayName) ✅ DONE
- [x] Merged - games work with OAuth users
- [x] Documented limitation (games break if displayName changes)
#### 1.4 Fix Headshot Fetching ✅ DONE
- [x] Made eagle0-headshots bucket public
- [x] Client fetches directly from CDN
- [x] No authentication required
#### 1.5 Add Lobby Status Display ✅ DONE
- [x] Show environment (prod/qa) in lobby
- [x] Show current user in lobby (OAuth displayName or classic username)
### Phase 2: Remaining Work (Priority Order)
#### 2.1 Diagnose Intermittent "Expired" Errors - IN PROGRESS
- [x] Add diagnostic logging (PR #4974)
- [ ] Deploy and reproduce the issue
- [ ] Analyze logs to identify root cause
- [ ] Implement fix based on findings
#### 2.2 Fix Display Name Uniqueness
- [ ] Investigate UserService.setDisplayName logic
- [ ] Check displayNameIndex population
- [ ] Add logging to trace the issue
- [ ] Fix the bug and add tests
#### 2.3 Wire Up Lobby UI in Unity
- [ ] Connect `lobbyEnvironmentText` to TextMeshProUGUI in scene
- [ ] Connect `lobbyUserText` to TextMeshProUGUI in scene
#### 2.4 Implement Token Refresh During Gameplay
- [ ] Implement `RefreshToken` RPC on server (currently throws UNIMPLEMENTED)
- [ ] Store refresh tokens server-side for validation
- [ ] Add proactive refresh in client before token expires
- [ ] Handle refresh during reconnection attempts
### Phase 3: Nice-to-Haves (Future)
#### 3.1 Proactive Token Refresh
- [ ] Monitor token expiry in client
- [ ] Refresh automatically when < 5 minutes remaining
- [ ] Update TokenStorage with new access token
#### 3.2 Better Error Messages
- [ ] Distinguish between network errors and auth errors
- [ ] Show user-friendly messages for OAuth failures
- [ ] Add retry suggestions
#### 3.3 Session Persistence Across Server Restarts
- [ ] Move pendingOAuth from in-memory TrieMap to Redis/database
- [ ] Move completedOAuth to Redis with TTL
- [ ] Server can restart without breaking in-flight OAuth flows
#### 3.4 Migrate to userId-based Game Identity (Deferred)
- [ ] Change `AuthorizationUtils.userName` to return `userId` for JWT users
- [ ] Add `UserService.resolveDisplayName(id: String): String` for UI display
- [ ] Update game UI to resolve userIds to displayNames
- [ ] Existing Basic Auth games continue to work (userName is literal)
#### 3.5 Display Name Change Support (Requires 3.4)
- [ ] Add `ChangeDisplayName` RPC
- [ ] Validate new name is unique
- [ ] Update user record
- [ ] No game migration needed (games use userId)
### Phase 3: Multi-Provider Support (Future)
#### 3.1 Account Linking UI
- [ ] Add Settings page with "Linked Accounts" section
- [ ] Show currently linked providers
- [ ] "Link Another Account" button triggers OAuth flow
- [ ] `LinkOAuthProvider` RPC adds identity to current user
#### 3.2 Login Provider Selection
- [ ] If user has multiple providers, any can be used to login
- [ ] All resolve to same userId
- [ ] Session shows which provider was used
#### 3.3 Account Merging (Complex)
- [ ] Handle case where user created separate accounts
- [ ] Merge game history, stats, etc.
- [ ] Delete duplicate user record
- [ ] This is complex - may defer or not implement
### Phase 4: Enhanced Avatars (Future)
#### 4.1 Avatar Caching
- [ ] Download avatars to S3/DO Spaces on login
- [ ] Serve from our CDN
- [ ] Refresh on login if changed
#### 4.2 Custom Avatar Upload
- [ ] Upload endpoint with size/format validation
- [ ] Store in S3/DO Spaces
- [ ] Custom avatar overrides OAuth avatar
---
## Technical Debt to Address
1. **Context Propagation in Futures**: PR #4960 fixed `setDisplayName` and `getCurrentUser`, but audit all `Future` blocks that access `AuthorizationUtils`
2. **Dual Auth Support**: The system supports both Basic Auth and JWT. Consider:
- Should Basic Auth be deprecated for production?
- Should it remain for local development only?
- How do Basic Auth users interact with OAuth users in the same game?
3. **Token Refresh**: `RefreshToken` RPC throws UNIMPLEMENTED. Need to:
- Implement refresh token storage and validation
- Handle token refresh in client
- Consider refresh token rotation for security
4. **Session Management**: No server-side session tracking. Consider:
- Track active sessions per user
- Allow "logout all devices"
- Detect concurrent logins
---
## Open Questions
1. **What happens when a Basic Auth user and OAuth user have the same name?**
- Currently possible - Basic Auth doesn't check UserService
- Could cause confusion in games
- Solution: Require OAuth for multiplayer? Or namespace Basic Auth names?
2. **Should displayName changes be allowed?**
- With userId-based identity, it's safe
- But could cause confusion ("who is this new player?")
- Consider: rate limit changes, show "formerly known as" temporarily
3. **How to handle OAuth provider account deletion?**
- User deletes their Discord account
- Their Eagle0 account still exists
- They can't login unless they linked another provider
- Solution: Encourage linking multiple providers, or add email/password fallback
4. **Admin impersonation with OAuth**
- Currently works via X-Impersonate-User header
- Should this use userId or displayName?
- Probably userId for stability
---
## Appendix: File Locations
### Server (Scala)
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - Token generation/validation
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth flow
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC Auth service
- `src/main/scala/net/eagle0/eagle/service/AuthorizationInterceptor.scala` - Auth middleware
- `src/main/scala/net/eagle0/eagle/service/AuthorizationUtils.scala` - Context accessors
### Client (C#)
- `Assets/Auth/AuthClient.cs` - gRPC client for Auth service
- `Assets/Auth/OAuthManager.cs` - OAuth flow orchestration
- `Assets/Auth/TokenStorage.cs` - Persistent token storage
- `Assets/Auth/JwtAuthInterceptor.cs` - Attaches JWT to requests
### Protos
- `src/main/protobuf/net/eagle0/eagle/api/auth.proto` - Auth service definition
- `src/main/protobuf/net/eagle0/eagle/internal/user/user.proto` - User data model
-149
View File
@@ -1,149 +0,0 @@
# Postgres History Migration Plan
## Goal
Evaluate and, if the measurements support it, migrate production game history persistence from local SQLite plus S3 base
and delta files to a managed Postgres database.
This is not just a mechanical port. The important product requirement is that battle commands, especially Shardok map
clicks, show results to the player as quickly as possible while still keeping the chance of lost results acceptably low.
## Proposed Direction
Use Postgres as the authoritative production store. Local SQLite should remain useful for development, tests, and rollback
during rollout, but production should not have two long-lived authoritative stores for the same game.
Start with a small DigitalOcean Managed Postgres instance in the same region and VPC as Eagle. The $15 single-node plan is
reasonable for early measurement and staging. If the design works, production can move to a more reliable plan when the
availability requirements justify it.
## Phase 1: Measure First
Before porting the full history implementation, add a benchmark or admin utility that runs from the Eagle deployment
environment against the candidate Postgres database.
Measure at least:
- single action-result append transaction latency
- batch append latency for 25 and 100 action results
- latest snapshot lookup
- replay from a snapshot to a target index
- `since` and stream-update query latency
- Shardok battle result append latency
- p50, p95, p99, and max latency
- async queue lag, if an async persistence prototype is included
Useful initial exit criteria:
- Strategic action-result writes should be comfortably below 50 ms p95.
- Shardok synchronous persistence should be below roughly 20-30 ms p95 to stay on the click-to-animation path.
- If Shardok sync writes are above that, use an async Shardok persistence design rather than blocking player feedback.
## Phase 2: Schema Prototype
Add Postgres configuration and a prototype schema that maps the current SQLite history model.
Expected tables:
- `games`, for game-level metadata and backend bookkeeping
- `action_results`, for ordered strategic action results
- per-entity decomposition tables currently written by the SQLite dispatchers
- `state_snapshots`, for replay anchors
- Shardok battle result tables, including battle id, sequence number, command/result payloads, and player metadata
- `schema_migrations`, unless the migration mechanism is handled outside the app
Prefer batched inserts and batched reads. The current SQLite implementation can afford some local-file patterns that will
be too chatty over a network.
## Phase 3: Implement Postgres History
Implement a `FullGameHistory` backend backed by Postgres. Keep the existing SQLite backend available behind a config flag
during rollout.
Core methods to support:
- `count`
- `last`
- `withNewResults`
- `stateAfter`
- `since`
- `sinceDate`
- round-local recent-result queries
- Shardok result writes and reads
The implementation should preserve action ordering and idempotency. If retries are possible, writes should include stable
keys or sequence ranges so replaying a write request cannot duplicate results.
## Phase 4: Decide Shardok Durability Mode
Shardok latency is the place where this migration can most visibly hurt the player. There are two viable modes.
### Option A: Synchronous Shardok Writes
Use this if measured Postgres latency is low enough. The command path writes results to Postgres before telling the client
about them.
This is the simplest durability model, but only acceptable if it keeps click-to-animation latency low.
### Option B: Async Shardok Writes
Use this if synchronous Postgres writes add noticeable latency.
The model:
- apply battle results to authoritative in-memory game state immediately
- send client updates immediately after the ordered in-memory apply
- enqueue ordered persistence batches in the same order they were applied
- persist batches with `battle_id`, sequence range, payload hash, and payload
- track `lastAppliedSeq` and `lastPersistedSeq`
- retry failed writes until they succeed
- alert when persistence lag exceeds a small threshold
- block or degrade new commands if the queue grows too large
- force a synchronous checkpoint before battle end reconciliation or strategic-layer handoff
- drain the queue during graceful shutdown
This creates a small crash-loss window, but only for rare crashes that happen after the player sees a result and before
the async write completes. That tradeoff may be better than adding network persistence latency to every tactical command.
## Phase 5: Rollout
Add a backend flag such as:
```text
EAGLE_HISTORY_BACKEND=sqlite|postgres
```
Suggested rollout:
1. Run benchmarks from the deployed environment.
2. Enable Postgres in staging for new games.
3. Run warmup games and Shardok battle smoke tests.
4. Enable Postgres for new production games.
5. Migrate old games either lazily on first open or through an admin-triggered migration job.
6. Keep SQLite/S3 rollback available until enough new-game production time has passed.
If Postgres is unavailable, commands should not be acknowledged as durable. Either hold/retry them before applying, or
reject them clearly. Do not show a result to the client while pretending it has already been durably stored unless that is
an explicit async-durability path with lag monitoring.
## Phase 6: Cleanup
After production confidence:
- remove production dependence on S3 base/delta history files
- keep object storage for backups, exports, or emergency archives
- keep SQLite for local development and tests if it remains useful
- document restore, migration, and rollback procedures
## Open Questions
- What exact durability guarantee do we want for "the client already saw this result"?
- What Shardok persistence latency is acceptable in real play?
- Should old games migrate lazily or through a bulk/admin process?
- When should the production database move from single-node to HA?
- What should the player experience be if Postgres is reachable but slow?
## Recommended Next PR
The next implementation PR should add a Postgres benchmark/admin utility and configuration plumbing, not the full port.
That gives us real latency numbers from the Eagle deployment environment before committing to the larger migration.
-218
View File
@@ -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/Scenes/Eagle.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
-79
View File
@@ -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/...`
-150
View File
@@ -1,150 +0,0 @@
# rules_apple Workspace Separation
This document explains why `rules_apple` is built in a separate Bazel workspace (`sparkle_workspace/`) and what conditions need to be met before it can be reintegrated into the main workspace.
## Background
The main workspace cannot include `rules_apple` due to transitive dependency conflicts with `rules_swift`. Multiple dependencies require different major versions of `rules_swift`:
| Dependency | rules_swift Version | Compatibility Level |
|------------|---------------------|---------------------|
| grpc | 3.x | 3 |
| flatbuffers | 2.x | 2 |
| rules_apple | 2.x | 2 |
Bzlmod's `single_version_override` can force a single version, but compatibility levels 2 and 3 are incompatible. Forcing `rules_swift` 3.x (required for grpc) breaks `rules_apple` and `flatbuffers`.
## Current Solution
The `SparklePlugin` (the only component requiring `rules_apple`) is built in an isolated workspace:
```
sparkle_workspace/
├── MODULE.bazel # Minimal deps: apple_support, rules_apple, sparkle
├── BUILD.bazel # Builds SparklePlugin.bundle
├── SparklePlugin.m # Objective-C source
├── Info.plist # Bundle metadata
└── external/
└── BUILD.sparkle # Build file for Sparkle framework
```
### How It Works
1. **Build script**: `scripts/build_sparkle_plugin.sh` builds the plugin from the separate workspace
2. **Output**: The built `SparklePlugin.bundle` is placed in `Assets/Plugins/macOS/`
3. **CI integration**: `mac_build.yml` calls `inject_sparkle.sh` to embed Sparkle into the Mac app
The main workspace uses `single_version_override` for `rules_swift` 3.x to satisfy grpc, and includes a comment noting that `rules_apple` is intentionally excluded.
## What SparklePlugin Does
SparklePlugin is a native macOS library that:
- Initializes the [Sparkle](https://sparkle-project.org/) auto-update framework
- Exposes C functions for Unity to call via P/Invoke:
- `SparklePlugin_Initialize`
- `SparklePlugin_CheckForUpdates`
- `SparklePlugin_CheckForUpdatesInBackground`
- `SparklePlugin_IsCheckingForUpdates`
- `SparklePlugin_GetAutomaticallyChecksForUpdates`
- `SparklePlugin_SetAutomaticallyChecksForUpdates`
- `SparklePlugin_IsRunningFromReadOnlyVolume`
- `SparklePlugin_ShowDMGWarning`
## Conditions for Reintegration
To move `rules_apple` back into the main workspace, **all** of the following must be true:
### 1. rules_swift Version Alignment
Check if grpc, flatbuffers, and rules_apple all support the same `rules_swift` major version:
```bash
# Check what rules_swift version each dependency requires
bazel mod graph 2>&1 | grep -A2 "rules_swift"
```
Or check BCR directly:
- https://registry.bazel.build/modules/grpc
- https://registry.bazel.build/modules/flatbuffers
- https://registry.bazel.build/modules/rules_apple
**Test**: After updating versions, verify you can add this to MODULE.bazel without errors:
```python
bazel_dep(name = "rules_apple", version = "X.Y.Z")
```
### 2. Build Test
If the dependency can be added, test that both the main build and SparklePlugin work:
```bash
# Main workspace builds
bazel build //src/main/scala/net/eagle0/eagle:eagle_server
bazel build //src/main/cpp/net/eagle0/shardok:shardok-server
bazel build //ci:eagle_server_image
# SparklePlugin builds (would move to main workspace)
bazel build //sparkle:SparklePlugin # After moving files
```
### 3. Files to Move
When reintegrating, move these from `sparkle_workspace/` to the main workspace:
| Source | Destination |
|--------|-------------|
| `sparkle_workspace/SparklePlugin.m` | `src/main/objc/net/eagle0/sparkle/SparklePlugin.m` |
| `sparkle_workspace/Info.plist` | `src/main/objc/net/eagle0/sparkle/Info.plist` |
| `sparkle_workspace/BUILD.bazel` | `src/main/objc/net/eagle0/sparkle/BUILD.bazel` |
| `sparkle_workspace/external/BUILD.sparkle` | (inline into MODULE.bazel http_archive) |
### 4. MODULE.bazel Changes
Add to the main MODULE.bazel:
```python
bazel_dep(name = "rules_apple", version = "X.Y.Z", repo_name = "build_bazel_rules_apple")
# Sparkle framework
http_archive(
name = "sparkle",
build_file_content = """...""", # Contents from external/BUILD.sparkle
sha256 = "...",
url = "https://github.com/sparkle-project/Sparkle/releases/download/...",
)
```
Remove the `single_version_override` for `rules_swift` if no longer needed.
### 5. Update Build Scripts
- Update `scripts/build_sparkle_plugin.sh` to build from main workspace
- Update `scripts/build_plugins.sh` if it references the separate workspace
- Verify `mac_build.yml` still works
### 6. Cleanup
After successful integration:
```bash
rm -rf sparkle_workspace/
```
Update comments in MODULE.bazel that reference the separate workspace.
## Version History
| Date | Event |
|------|-------|
| 2026-02-04 | Separated sparkle_workspace (PR #5882) to enable Bazel 8 upgrade |
| 2026-02-05 | Upgraded to Bazel 8.5.1 (PR #5883) |
## Monitoring
Periodically check if the upstream dependencies have aligned:
1. **grpc releases**: https://github.com/grpc/grpc/releases
2. **flatbuffers releases**: https://github.com/google/flatbuffers/releases
3. **rules_apple releases**: https://github.com/bazelbuild/rules_apple/releases
4. **BCR updates**: https://registry.bazel.build/
When a new version of any of these is released, check if the `rules_swift` requirements have converged.
+205
View File
@@ -0,0 +1,205 @@
# Scala 3 Modernization Guide
## Overview
This document outlines opportunities to modernize the Eagle0 codebase to use Scala 3 best practices and features. The migration to Scala 3 is complete, but the code still uses many Scala 2 patterns that can be improved.
## Modernization Opportunities
### 1. **Convert Sealed Traits to Enums** 🎯 HIGH IMPACT
**Benefits**: Better performance, more concise syntax, improved exhaustiveness checking
**Current pattern** (`ExternalTextGenerationCaller.scala:23-31`):
```scala
sealed trait ExternalTextGenerationError extends Error {
def message: String
}
case class ExternalTextGenerationRateLimitError(code: Int, message: String)
extends ExternalTextGenerationError
case class ExternalTextGenerationHttpError(code: Int, message: String)
extends ExternalTextGenerationError
case class ExternalTextGenerationTimeoutError(message: String)
extends ExternalTextGenerationError
```
**Scala 3 improvement**:
```scala
enum ExternalTextGenerationError extends Error:
case RateLimit(code: Int, message: String)
case Http(code: Int, message: String)
case Timeout(message: String)
def message: String = this match
case RateLimit(_, msg) => msg
case Http(_, msg) => msg
case Timeout(msg) => msg
```
**Files to check**:
- `/src/main/scala/net/eagle0/common/llm_integration/ExternalTextGenerationCaller.scala`
- `/src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request/GeneratedTextRequestT.scala`
- `/src/main/scala/net/eagle0/eagle/model/state/quest/concrete/QuestC.scala`
### 2. **Convert Implicit Classes to Extension Methods** 🎯 HIGH IMPACT
**Benefits**: Modern syntax, better IDE support, cleaner imports
**Current pattern** (`MoreSeq.scala:23-26`):
```scala
implicit def SeqCollect[A, Repr[_]](coll: Repr[A])(implicit
itr: IsIterable[Repr[A]]
): SeqCollect[A, Repr, itr.type] =
new SeqCollect[A, Repr, itr.type](coll, itr)
```
**Scala 3 improvement**:
```scala
extension [A, Repr[_]](coll: Repr[A])(using itr: IsIterable[Repr[A]])
def flatCollect[B](pf: PartialFunction[itr.A, Option[B]])(using Factory[B, Repr[B]]): Repr[B] =
Factory[B, Repr[B]].fromSpecific(itr(coll).collect(pf).flatten)
def flatCollectFirst[B](pf: PartialFunction[itr.A, Option[B]]): Option[B] =
itr(coll).collect(pf).flatten.headOption
```
**Files to check**:
- `/src/main/scala/net/eagle0/common/MoreSeq.scala`
- `/src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/CommandChooser.scala`
- `/src/main/scala/net/eagle0/eagle/service/new_game_creation/NewGameCreation.scala`
- `/src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultProtoApplierImpl.scala`
- `/src/main/scala/net/eagle0/eagle/service/new_game_creation/StartGameActionResultUtils.scala`
- `/src/main/scala/net/eagle0/eagle/model/state/date/Date.scala`
### 3. **Convert Implicit Parameters to Using Clauses** 🎯 MEDIUM IMPACT
**Benefits**: Cleaner syntax, better tooling support, clearer intent
**Current pattern**:
```scala
def method[T](value: T)(implicit ec: ExecutionContext): Future[T]
def process[A](items: Seq[A])(implicit ord: Ordering[A]): Seq[A]
```
**Scala 3 improvement**:
```scala
def method[T](value: T)(using ExecutionContext): Future[T]
def process[A](items: Seq[A])(using Ordering[A]): Seq[A]
```
**Files to check**:
- `/src/main/scala/net/eagle0/common/MoreSeq.scala`
- `/src/main/scala/net/eagle0/eagle/library/util/hero_name_fetcher/HeroNameFetcher.scala`
- `/src/main/scala/net/eagle0/eagle/library/util/ShardokMapInfo.scala`
- `/src/main/scala/net/eagle0/common/llm_integration/OpenAIChatCompletionsServiceImpl.scala`
- `/src/main/scala/net/eagle0/common/llm_integration/ClaudeServiceImpl.scala`
### 4. **Opaque Types for Type Safety** 🎯 MEDIUM IMPACT
**Benefits**: Zero runtime cost, compile-time type safety, prevents mixing up similar types
**Pattern to look for**: Type aliases that represent distinct concepts
```scala
// Instead of: type UserId = String, type GameId = String
opaque type UserId = String
object UserId:
def apply(s: String): UserId = s
extension (id: UserId)
def value: String = id
def isValid: Boolean = id.nonEmpty && id.length > 3
opaque type GameId = Long
object GameId:
def apply(l: Long): GameId = l
extension (id: GameId) def value: Long = id
```
**Candidates**: Look for simple type aliases and ID types throughout the codebase.
### 5. **Inline Methods for Performance** 🎯 LOW IMPACT
**Benefits**: Compile-time optimization, better performance for hot paths
**Pattern**: Mark small, frequently-called methods as `inline`
```scala
inline def isValidId(id: String): Boolean =
id.nonEmpty && id.length > 3
inline def calculateScore(base: Int, multiplier: Double): Double =
base * multiplier
```
**Candidates**: Small utility methods in performance-critical paths (AI calculations, game state updates).
### 6. **Union Types Instead of Complex Hierarchies** 🎯 LOW IMPACT
**Benefits**: Simpler type definitions for either/or scenarios
**Pattern**: Simple sealed traits with only case classes
```scala
// Instead of:
sealed trait Result
case class Success(value: String) extends Result
case class Error(message: String) extends Result
// Consider:
type Result = Success | Error
case class Success(value: String)
case class Error(message: String)
```
### 7. **Context Functions for Cleaner APIs** 🎯 LOW IMPACT
**Benefits**: Cleaner API design, implicit context passing
**Pattern**: Replace implicit function parameters
```scala
// Old
type Handler = GameState => Unit
def withGameState(gs: GameState)(handler: Handler): Unit = handler(gs)
// New
type Handler = GameState ?=> Unit
def withGameState(gs: GameState)(handler: Handler): Unit =
given GameState = gs
handler
```
## Implementation Priority
### Phase 1: Quick Wins (High Impact, Low Risk)
1. **Convert Extension Methods** in `MoreSeq.scala` - immediate readability improvement
2. **Update Using Clauses** - simple find/replace operation
3. **Convert Simple Sealed Traits to Enums** - start with error types
### Phase 2: Type Safety Improvements
4. **Add Opaque Types** for IDs and measurements - improves type safety
5. **Inline Performance-Critical Methods** - measure before/after impact
### Phase 3: Advanced Features (Lower Priority)
6. **Union Types** where appropriate - only for simple either/or cases
7. **Context Functions** for complex API improvements
## Implementation Guidelines
### Style Consistency
- **Keep curly braces**: Continue using Scala 2 style `{}` instead of indentation-based syntax
- **Gradual adoption**: Modernize files as they're touched for other reasons
- **Test thoroughly**: Each modernization should include verification that behavior is unchanged
### Performance Considerations
- **Measure enum performance**: Verify that enum conversion actually improves performance in hot paths
- **Benchmark inline methods**: Use profiling to confirm performance gains
- **Consider compilation time**: Some features may increase compile time
### Migration Strategy
- **File-by-file approach**: Complete modernization of one file at a time
- **Separate PRs**: Each modernization type should be its own PR for easier review
- **Documentation**: Update this document as patterns are modernized
## Success Criteria
- [ ] All extension methods converted from implicit classes
- [ ] All implicit parameters converted to using clauses
- [ ] Key sealed traits converted to enums where appropriate
- [ ] Opaque types introduced for important ID types
- [ ] Performance-critical methods marked as inline (with benchmarks)
- [ ] No regression in functionality or performance
- [ ] Code remains readable and maintainable
## Notes
- Focus on high-impact, low-risk improvements first
- Each change should be driven by clear benefits (performance, readability, type safety)
- Maintain backward compatibility where possible
- Document any breaking changes clearly
@@ -1,77 +0,0 @@
# Shardok AI Experiment Tooling Plan
## Summary
Build a reusable workflow for Shardok AI battle baselines and scoring experiments. The goal is to
turn the current one-off benchmark process into repeatable commands that extract real battles, run
baseline and candidate configurations, capture detailed per-command traces, and write comparable
reports.
This work starts with the target-aware archery scoring experiment: attacker `EXPERIMENTAL` against
defender `STANDARD`.
## Key Changes
- Add simulator trace support to `ai_battle_simulator_main`.
- New flag: `--trace-jsonl=<path>`.
- Emit one JSONL row per AI-selected command.
- Include run label, config path, state file, phase, sequence, round, player, side, command type,
actor unit, target coords, chosen index, available command count, search depth, commands
evaluated, completion reason, forced commands posted afterward, and pre/post side troop totals.
- Keep existing summary output unchanged.
- Add reusable benchmark tooling under
`src/main/cpp/net/eagle0/shardok/ai_battle_simulator/benchmarks/`.
- `real_battle_suite.py`: shared helpers for reading `game.db`, materializing battle/state/result
blobs, extracting configs, running simulator binaries, parsing summaries, and collecting
real-game metadata.
- `run_real_battle_experiment.py`: CLI orchestration for baseline/candidate runs.
- Outputs: `summary.csv`, `comparison.csv`, `report.md`, generated configs, and per-run trace
JSONL files.
- Add a clean EXPERIMENTAL switch for target-aware archery value.
- Use a new experiment id.
- Change only archery-availability scoring.
- Replace the flat archery-possible value with a target-sensitive value based on the best enemy
unit value.
- Do not include the previous fear-value change from experiment `27`.
## Execution Plan
- Create a feature branch from fetched `origin/main`.
- Commit this plan document first.
- Implement simulator trace output and reusable benchmark tooling.
- Add the target-aware archery-only EXPERIMENTAL switch.
- Run the new tool first on battle `6248`, then on the all-40 saved-battle suite if local runtime is
acceptable.
- Compare baseline attacker `STANDARD` / defender `STANDARD` with candidate attacker
`EXPERIMENTAL` / defender `STANDARD`.
- Record the results under the existing benchmark results area and call out battle `6248` in the
generated report.
- Push the branch and create a PR after local validation.
## Tests
- Build:
- `bazel build //src/main/cpp/net/eagle0/shardok/ai_battle_simulator:ai_battle_simulator_main`
- `bazel build //src/main/cpp/net/eagle0/shardok/ai_battle_simulator:real_battle_config_extractor`
- `bazel build //src/main/cpp/net/eagle0/shardok/ai_battle_simulator:real_battle_smoke_metadata`
- Unit/integration:
- `bazel test //src/test/cpp/net/eagle0/shardok/ai_battle_simulator:ai_battle_simulator_test`
- Add or extend a simulator test that verifies trace output is created and contains required
command fields.
- `python3 -m py_compile` on the new benchmark scripts.
- Validation:
- Run the new tool on battle `6248`.
- Run the new tool on all 40 battles if feasible locally.
- Run `bazel test //src/test/cpp/...` before push if local time allows; otherwise open the PR
promptly and continue validation while CI starts.
## Assumptions
- "Experiment 3" means target-aware archery value from the previous recommendation list.
- The first experiment compares attacker-only candidate behavior against unchanged defender
`STANDARD`.
- JSONL is the trace format because command diagnostics will evolve over time.
- Python benchmark scripts follow the existing executable-script pattern rather than adding Python
Bazel targets.
-82
View File
@@ -1,82 +0,0 @@
# Shardok AI Scoring Benchmarks
The current STANDARD scoring experiment results are recorded in
`src/main/cpp/net/eagle0/shardok/ai_battle_simulator/benchmarks/experimental_results/`.
They use 40 saved Shardok battles extracted from `game_389ffb1901903118.zip`.
The corrected all-40 run uses:
- `max_rounds = 31`
- `random_seed = 1`
- iterative-deepening timeout propagation fix from PR #6923
- AI setup placement
- forced single-option commands
- defender scoring fixed to `STANDARD` for every row
- archery/start-fire capability derived like production setup
- per-unit flee capability preserved by real-battle extraction
- zero-troop combat heroes preserved as live units
- `NO_PROFESSION` treated as a valid hero profession state, not as "no hero"
Shardok siege battles do not have a draw result. If the attacker has not won after 31 rounds, the
defender wins. The simulator records those cutoffs as `max_rounds_reached` defender wins.
All 40 saved battle payloads now produce valid simulator configs. A battle side can have zero
remaining troops as long as it has a hero; this is common for `NO_PROFESSION` heroes with no
commanded battalion.
An all-40 real-vs-sim comparison records whether the saved-game attacker/defender were human or AI
players. Factions `3` and `4` are human players; all other factions in this save are AI players.
The comparison uses broad winner side from the saved Shardok result payload, because exact replay
is not available from the compact result rows. Real end-of-battle troop totals come from the
all-factions Shardok `ActionResultView` stream's final `changed_player_totals`.
An AI-vs-AI smoke check also reruns a subset where neither side used faction `3` or `4`.
## Current Results
These artifacts are the current baseline for future scoring comparisons.
| Dataset | Valid battles | Invalid rows | Attacker wins | Defender wins | Notes |
| --- | ---: | ---: | ---: | ---: | --- |
| All-40 timeout-fix STANDARD baseline | 40 | 0 | 18 | 22 | `standard_scoring_all40_post_bugfix_baseline.*`; regenerated after fixing iterative-deepening timeout score propagation |
| All-40 real saved game | 40 known | 0 unknown | 19 | 21 | `standard_scoring_all40_real_vs_sim.*`; sim matches 39/40 known winners |
| AI-vs-AI smoke | 31 | 0 | 10 | 21 | 31/31 winner-side matches |
The remaining real-vs-sim winner-side miss is `11754` (real attacker, sim defender), which had an
AI attacker but a human defender in the saved game. The real attacker won with `2119` troops
remaining; the STANDARD-vs-STANDARD simulation replaces the human defender with AI and the defender
wins with `1000` troops remaining, so this row is not a comparable AI-vs-AI smoke miss. Battle
`6248`, previously the remaining AI-vs-AI miss, now matches as an attacker win after expected-impact
archery scoring was promoted to STANDARD.
The closest six valid baseline rows are used for the scalar and behavior experiment batches. The
single-run harness still has some nondeterminism even with `random_seed = 1`, so near-margin changes
should be repeated before being promoted.
## Experiment Tooling
`src/main/cpp/net/eagle0/shardok/ai_battle_simulator/benchmarks/run_real_battle_experiment.py`
is the reusable real-battle experiment runner. It can read a saved-game zip or extracted `game.db`,
generate baseline/candidate simulator configs, run both sides, and write `summary.csv`,
`comparison.csv`, `report.md`, generated configs, and per-command trace JSONL files.
The repository keeps the current STANDARD baseline artifacts, but omits generated configs/traces
for abandoned experiment batches. Regenerate those locally with the runner when a new comparison
needs command-level traces.
`src/main/cpp/net/eagle0/shardok/ai_battle_simulator/benchmarks/run_standard_baselines.py`
regenerates the tracked STANDARD-vs-STANDARD baseline artifacts from a saved-game zip or extracted
`game.db`, including the all-40 baseline, real-vs-sim comparison, and AI-vs-AI smoke files.
The simulator supports `--trace-jsonl=<path>` and `--run-label=<label>` for detailed command traces.
Each row records the selected command, search depth, forced command count, and pre/post side troop
totals.
Expected-impact archery replaced the old flat archery-position value in STANDARD. A unit that can
shoot now gets half of the expected volley impact; a unit that is merely positioned for a future
volley gets one quarter. Promoting that behavior flips `6248` to match the real AI-vs-AI attacker
win without introducing an AI-vs-AI winner-side miss in the current saved-battle baseline.
The latest baseline refresh was generated after the iterative-deepening evaluator stopped letting
timeout fallback scores compete with completed command evaluations. Future STANDARD-vs-EXPERIMENTAL
comparisons should compare against these checked-in timeout-fix baseline artifacts.
-89
View File
@@ -1,89 +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.
## Current priority order
1. Finish tutorial & first-session onboarding, especially the clear first-session goal and guided first scenario.
2. Fix the basic Shardok AI issues that can make tactical battles feel broken or unfair.
3. Publish a known issues doc so alpha testers do not repeatedly report the same rough edges.
4. Verify the existing game-ended flow, then mark the win condition done if "all other factions defeated" works end-to-end.
5. Add mid-game progression events where the King recognizes the player as they gain power.
6. Add account-linking when provider switching or account recovery becomes important for alpha support.
7. Leave terms of service and Windows code signing deferred until they matter for public release or broader distribution.
## 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~~
- [x] 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 & first-session onboarding
- [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~~
- [x] ~~Shardok tutorial!~~
- [x] ~~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)
- [x] ~~Early small victory to build momentum~~
- [ ] Guided first scenario vs. overwhelming sandbox?
- [ ] Basic Shardok AI stuff fixed
- [x] ~~Lobby fixes~~
- [ ] Have goals / ending
- [ ] Win condition: all other factions defeated
- [ ] Mid-game progression: King recognizes you as you gain power (generated events)
## 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
-568
View File
@@ -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
-335
View File
@@ -1,335 +0,0 @@
# SQLite Action Result Decomposition
Follow-up to [SQLITE_HISTORY_DESIGN.md](SQLITE_HISTORY_DESIGN.md). This document plans the second wave of the SQLite migration: decomposing the `ActionResult` proto blob in `action_results.payload` into native relational tables, deleting the proto representation entirely for everything in scope.
## Status — option B adopted (updated 2026-05-17)
**This section supersedes the original plan below. Where the body conflicts with this section, this section wins.** The full-relational design is kept below for historical context and as the migration target if a structured-query need on deep aggregates ever materializes.
The original plan (decompose every field into relational tables; "decision locked") was implemented for `ChangedProvince` first — ~37 child tables, ~1500 lines (PR #6721) — and then **rejected on cost/benefit**. We adopted **option B**: apply the "stopping rule" (originally carved out only for `Quest`) *consistently*. Shipped and merged as Phase 4.5b in **PR #6725**:
- Keep the per-entity **top-level table with scalar / entity-reference columns + indexes** (the 4.5a tables). That is the part that earns its keep — "which actions changed province/hero/faction X, by how much" is one-line SQL.
- Store the **rest of the aggregate as one `<entity>_proto BLOB` column** on that row: lossless round-trip / replay, no child tables for the deep nesting.
- Scalar columns are pure query denormalization; **the proto blob is authoritative**. A later fully-relational pass (if ever needed) is a pure read-blob → write-columns migration — which is why the blob must survive any future proto deletion.
Rationale: the deep army/unit/hero/claimant nesting is never analytics-queried — the exact property that justified blobbing `Quest`. Exploding it into ~37 tables was code volume without query value. The blob also means deep-aggregate field changes need **no SQL migration** (only promoted scalar columns do), which is the right shape for the volatile parts of the model. This rescinds "Decisions locked #1" and revises principle 7, the Scope, the Sequencing table, and "Deleting the proto" (all updated in place below).
## Refinement — "refined option B" for shallow entities (updated 2026-05-19)
**This section further supersedes the body and the Status section above where they conflict; it wins.** It records the pattern actually shipped for `ChangedHero` in Phase 4.5c.1 (#6727), which is a refinement of option B, not the whole-entity-blob shape the body still describes for that entity.
Option B as originally stated keeps **one `<entity>_proto BLOB`** per row for the entire non-scalar residual. For a *shallow* entity that is more blob than it needs. Refined option B decomposes the residual into three tiers instead of one blob:
- **Tier 1 — scalar / entity-reference columns + indexes.** The queryable surface (deltas, touched entities, enum ordinals). Always present; unchanged from 4.5a.
- **Tier 2 — typed columns for sealed value-oneofs.** A sealed choice over simple values (e.g. `ChangedHeroC`'s `loyalty` / `vigor` `StatChange`, subtypes `StatDelta`/`StatAbsolute`/`StatNoChange`) becomes a **pair of nullable columns** (`loyalty_delta` / `loyalty_absolute`, …). The *column name is the discriminator*; at most one of each pair is non-NULL; both NULL ⇒ the no-change case. No `kind` column.
- **Tier 3 — per-row leaf-proto blobs in a child table.** A repeated/optional nested aggregate becomes a child table keyed by `(action_seq, entity_id, n)` storing the **leaf** proto's bytes one row per element (e.g. `action_changed_heroes_new_backstory_events` holding `EventForHeroBackstory` blobs) — *not* a whole-entity blob.
**Consequence (changes Decisions #1#3 and 4.5i):** an entity fully covered by Tiers 13 has **no whole-entity blob**. It round-trips losslessly from columns + per-leaf blobs, so its per-entity proto and converter (`changed_hero.proto` / `ChangedHeroConverter`) become genuinely dead code, deletable in 4.5i. Only the **leaf** protos used as Tier-3 blob payloads (`event_for_hero_backstory.proto`) are retained as a wire format. The whole-entity `<entity>_proto BLOB` is now reserved for **deep** entities where Tiers 23 would be more work than value (`ChangedProvince`, the "new entity" snapshots).
Per-entity decision (supersedes Decisions #2): classify each entity as **all-scalar** (Tier 1 only, no blob — e.g. likely `ChangedFaction`), **refined** (Tiers 13, no whole-entity blob, proto deletable — `ChangedHero`), or **deep** (Tier 1 + whole-entity `<entity>_proto BLOB`, proto retained — `ChangedProvince`, new-entity snapshots). Default to **refined** for shallow entities; fall back to **deep** only when the residual is too varied to be worth Tiers 23. Wherever the body or Status says "the proto blob is authoritative," for a *refined* entity read "the columns + per-leaf-proto blobs are authoritative."
## Why
The first wave (Phases 14) stores each action as a single proto blob in `action_results.payload`. That blob is opaque to SQL — any query about action contents (changed provinces, heroes affected, etc.) requires deserializing every row. The `action_result_type`, `round_id`, and date columns we denormalized are the only handles SQL has into actions.
Two observations make full decomposition more attractive than the original design admitted:
1. **The proto exists for storage only.** 92 Scala files reference `ChangedProvince`, but exactly one imports the proto type — `ChangedProvinceConverter.scala`. Everything else (action handlers, AI, utils) operates on `ChangedProvinceC`. The proto is **not** in any client-facing RPC contract — it lives under `eagle.internal`. Same is true for `ChangedHero`, `ChangedFaction`, `ChangedBattalion`, `Notification`, `GeneratedTextRequest`, and `ActionResult` itself. Replacing proto serialization with SQL writes does **not** lose forward/backward compatibility, because there are no external consumers depending on the proto wire format.
2. **`ActionResultC` has no polymorphism at the top level.** It's a 34-field flat struct — `Option`s and `Vector`s, but no `sealed trait` discriminator at the action level. Every action has the same shape; the `actionResultType` enum tells you *which fields* are populated. This means the entire structure is relationalizable without per-action-type tables.
The work is bounded, the proto becomes dead code at the end of it, and it has to happen before alpha — once we have users, every schema change costs a real migration. Doing it pre-alpha lets us nuke save dirs as part of deployment.
## Scope
**In:** `action_results.payload` (the whole-`ActionResult` blob) is gone by the end. Per-entity scalar / entity-reference columns make the *queryable* surface (deltas, touched entities) native SQL. **Under refined option B (see Status):** `internal/action_result.proto` and *deep* entities' sub-message protos (`changed_province.proto`, …) are retained as blob payloads; *refined* entities' protos (`changed_hero.proto`) are decomposed away and deleted, with only their Tier-3 leaf protos (`event_for_hero_backstory.proto`) kept. (The original plan deleted all of them, then a draft of option B kept all of them; neither holds — it's now per-entity.)
**Out:**
- `state_snapshots.payload` — stays as proto-blob `GameState`. These are caches for replay, not authoritative data; querying inside a snapshot adds nothing over querying the action deltas that produced it. Cost/benefit doesn't justify decomposing them.
- Shardok tables — already designed in Phase 3, no changes here. The shardok protos (`ShardokActionResult`, etc.) are owned by the Shardok subsystem and are part of an actual RPC contract; they stay.
- Client text — already SQLite-backed (`SqliteClientTextStore`).
## Principles
1. **One table per non-trivial aggregate type.** Every `Vector[X]` or `Option[X]` where `X` is a Scala case class with multiple fields gets its own table.
2. **Vectors of primitives → join tables.** `Vector[Int]` becomes a two-column table: `(parent_key, value)`. No JSON arrays, no comma-separated strings — those defeat the point.
3. **Optional aggregates → presence by row existence.** `Option[X]` for aggregate `X` is a row that may or may not exist with the matching parent key. The natural primary key on `(action_seq)` enforces 0-or-1-row semantics for `Option[X]` aggregates and `(action_seq, n)` for vectors.
4. **Sealed traits get a discriminator column.** When a Scala sealed trait has a few subtypes with diverging fields, prefer one wide table with `kind TEXT NOT NULL` + nullable per-subtype columns over per-subtype tables. When subtypes diverge significantly (>5 unrelated fields each), use per-subtype tables. Decide per case.
5. **`ON DELETE CASCADE` everywhere.** Every child table has `FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE`. `truncateTo` becomes a single `DELETE FROM action_results WHERE action_seq >= ?` and the cascades do the rest.
6. **Schema migrations are now real.** Once we have a relational schema, evolving it means SQL migrations. We introduce a real migration runner (see "Schema evolution" below). The `metadata` table's `schema_version` becomes load-bearing.
7. **(Revised under option B — see Status.) The proto blob is the retained authoritative store for each entity's deep aggregates; scalar / entity-reference columns are denormalized projections of it.** The original principle assumed full decomposition and proto deletion. Instead each entity keeps one proto-blob source of truth and denormalizes only the queryable columns from the same write — so there is no dual *relational* representation to keep in sync.
## Top-level: `action_results`
The `payload BLOB` column goes away. Scalar fields become columns; aggregate fields move to related tables.
```sql
CREATE TABLE action_results (
action_seq INTEGER PRIMARY KEY,
action_result_type INTEGER NOT NULL,
-- existing scalar denormalizations
round_id INTEGER NOT NULL,
date_year INTEGER,
date_month INTEGER,
-- new scalar columns (formerly inside payload)
acting_hero_id INTEGER,
acting_faction_id INTEGER,
province_id INTEGER,
province_id_acted INTEGER,
new_round_phase INTEGER,
new_round_id INTEGER,
last_command_type_for_acting_province INTEGER,
resolved_battle TEXT,
new_victor_faction_id INTEGER,
game_ended INTEGER, -- 0/1/NULL
new_random_seed INTEGER,
new_game_type INTEGER
);
-- Vectors of bare IDs become join tables:
CREATE TABLE action_destroyed_battalion_ids (
action_seq INTEGER NOT NULL,
battalion_id INTEGER NOT NULL,
PRIMARY KEY (action_seq, battalion_id),
FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE
);
CREATE TABLE action_removed_hero_ids (
action_seq INTEGER NOT NULL,
hero_id INTEGER NOT NULL,
PRIMARY KEY (action_seq, hero_id),
FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE
);
CREATE TABLE action_removed_faction_ids (...);
CREATE TABLE action_affected_faction_ids (...);
-- Singleton optional aggregates: a row exists if and only if Option is Some.
CREATE TABLE action_new_battles (
action_seq INTEGER PRIMARY KEY,
-- ShardokBattle fields here, columns or sub-tables as needed
FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE
);
CREATE TABLE action_new_chronicle_entries (action_seq INTEGER PRIMARY KEY, ...);
CREATE TABLE action_new_eagle_map_info (action_seq INTEGER PRIMARY KEY, ...);
```
Each `Vector[Aggregate]` field on `ActionResultC` gets its own table (covered below).
### Indexes
We keep the existing indexes (`round_id`, `(date_year, date_month)`) and add a few that obvious queries need:
- `idx_action_results_acting_hero_id`
- `idx_action_results_acting_faction_id`
- `idx_action_results_province_id`
Plus indexes on the per-entity join tables on the entity-id side, so cross-game queries like "all actions touching hero X" become single-index lookups:
- `idx_action_changed_provinces_province_id`
- `idx_action_changed_heroes_hero_id`
- `idx_action_changed_faction_faction_id`
- `idx_action_changed_battalions_battalion_id`
## Changed-entity tables (four parallel structures)
### `action_changed_provinces`
`ChangedProvinceC` has ~50 fields: scalar deltas, nested aggregates, ID vectors. The scalar fields become columns on `action_changed_provinces`; aggregate fields become child tables.
```sql
CREATE TABLE action_changed_provinces (
action_seq INTEGER NOT NULL,
province_id INTEGER NOT NULL,
-- resource changes
gold_delta INTEGER,
food_delta INTEGER,
-- stat changes
new_price_index REAL,
economy_delta REAL,
agriculture_delta REAL,
infrastructure_delta REAL,
economy_devastation_delta REAL,
agriculture_devastation_delta REAL,
infrastructure_devastation_delta REAL,
support_delta REAL,
-- round properties
set_has_acted INTEGER, -- 0/1/NULL
set_ruler_is_traveling INTEGER,
-- ruling faction changes
clear_ruling_faction_id INTEGER NOT NULL DEFAULT 0,
new_ruling_faction_id INTEGER,
-- misc scalar fields
new_locked_improvement_kind TEXT, -- 'none', 'new'
new_locked_improvement_value INTEGER, -- ImprovementType if kind='new'
new_province_orders INTEGER, -- ProvinceOrderType enum
clear_defending_army INTEGER NOT NULL DEFAULT 0,
cleared_pending_conquest_info INTEGER NOT NULL DEFAULT 0,
removed_deferred_change_index INTEGER,
PRIMARY KEY (action_seq, province_id),
FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE
);
CREATE INDEX idx_action_changed_provinces_province_id
ON action_changed_provinces(province_id);
```
The vector and optional-aggregate fields of `ChangedProvinceC` become per-aggregate child tables, all keyed by `(action_seq, province_id)`:
- `action_changed_provinces_new_unaffiliated_heroes(action_seq, province_id, n, ...)` — one row per added hero, with `UnaffiliatedHero` fields as columns
- `action_changed_provinces_changed_unaffiliated_heroes(...)` — same shape
- `action_changed_provinces_removed_unaffiliated_hero_ids(action_seq, province_id, hero_id)` — join table for ID vector
- `action_changed_provinces_new_captured_heroes(...)``CapturedHero` fields
- `action_changed_provinces_recruitment_attempted_captured_hero_ids(...)` — join table
- `action_changed_provinces_removed_captured_hero_ids(...)` — join table
- `action_changed_provinces_new_ruling_faction_hero_ids(...)` — join table
- `action_changed_provinces_removed_ruling_faction_hero_ids(...)` — join table
- `action_changed_provinces_new_battalion_ids(...)` — join table
- `action_changed_provinces_removed_battalion_ids(...)` — join table
- `action_changed_provinces_new_incoming_armies(...)``MovingArmy` decomposition
- `action_changed_provinces_removed_incoming_army_ids(...)` — join table
- `action_changed_provinces_new_withdrawing_armies(...)``MovingArmy` decomposition
- `action_changed_provinces_removed_withdrawing_army_ids(...)` — join table
- `action_changed_provinces_new_hostile_armies(...)``HostileArmyGroup` decomposition
- `action_changed_provinces_removed_hostile_army_faction_ids(...)` — join table
- `action_changed_provinces_hostile_army_status_changes(action_seq, province_id, faction_id, new_status)` — small flat aggregate
- `action_changed_provinces_new_defending_army``Option[Army]` (singleton row if present)
- `action_changed_provinces_new_incoming_shipments(...)``MovingSupplies` decomposition
- `action_changed_provinces_removed_incoming_shipment_ids(...)` — join table
- `action_changed_provinces_new_incoming_end_turn_actions(...)``IncomingEndTurnAction` decomposition
- `action_changed_provinces_removed_incoming_end_turn_actions(...)` — same shape (these are values, not IDs)
- `action_changed_provinces_new_battle_revelations(...)``BattleRevelation` decomposition
- `action_changed_provinces_removed_battle_revelations(...)` — same shape
- `action_changed_provinces_new_pending_conquest_info``Option[PendingConquestInfo]` (singleton row)
- `action_changed_provinces_new_province_events``Vector[ProvinceEvent]`
- `action_changed_provinces_new_deferred_change``Option[DeferredChange]` (singleton, with discriminator since `DeferredChangeT` is sealed)
That's a lot of tables — ~25 just under `ChangedProvince`. The schema is mechanical once the principles are set; what makes it tractable is that every table follows the same shape: parent FK, `n` (vector index) where ordering matters, the aggregate's fields as columns, child aggregates as their own tables.
### `action_changed_heroes`, `action_changed_factions`, `action_changed_battalions`
Same pattern. Each gets its top-level table keyed by `(action_seq, entity_id)`, plus child tables for nested aggregates and ID vectors. Indexes on the entity id (`hero_id`, `faction_id`, `battalion_id`) so cross-game queries are fast.
`ChangedHeroC` is a *refined* entity (see "Refinement" in Status): no whole-entity blob. Its sealed `StatChange` (`StatDelta`/`StatAbsolute`/`StatNoChange`) `loyalty` and `vigor` oneofs are Tier-2 column pairs (`loyalty_delta`/`loyalty_absolute`, `vigor_delta`/`vigor_absolute`; column name discriminates, both-NULL ⇒ `StatNoChange`). Its `newBackstoryEvents: Vector[EventForHeroBackstory]` is Tier-3: per-event `EventForHeroBackstory` blobs, one row each, in `action_changed_heroes_new_backstory_events`. Columns + per-event blobs round-trip losslessly, so `changed_hero.proto`/`ChangedHeroConverter` become deletable (4.5i) and only `event_for_hero_backstory.proto` is retained as a blob format.
## "New" entity tables
`ActionResultC.newProvinces: Vector[ProvinceT]`, `newHeroes`, `newFactions`, `newBattalions`. These are *full snapshots* of new entities at creation.
These are bigger and recursive: `ProvinceT` itself has fields including its armies, heroes, battalions, events, etc. Decomposing fully duplicates the structure already covered by the existing `state_snapshots` blob (which we're explicitly keeping). The decomposition still needs to happen because we're dropping the proto entirely — and these vectors live in `ActionResultC`, which we're decomposing.
**Decision**: go fully relational here too. Each "new entity" gets its top-level table (`action_new_provinces` etc.) keyed by `(action_seq, entity_id)`, with the same per-aggregate-field decomposition pattern as the changed-entity tables. Where a "new entity" type contains its own nested aggregates, those aggregates either share child tables with the `changed_*` family (preferred where the field shape is identical, e.g., `MovingArmy`, `UnaffiliatedHero`) or get their own (where they diverge).
This is the most expensive single chunk of the decomposition — explicitly called out so we know what we're committing to in PR 4.5f.
## Notifications and generated text requests
```sql
CREATE TABLE action_new_notifications (action_seq INTEGER NOT NULL, n INTEGER NOT NULL, ...);
CREATE TABLE action_removed_notifications (action_seq INTEGER NOT NULL, n INTEGER NOT NULL, ...);
CREATE TABLE action_new_generated_text_requests (...);
CREATE TABLE action_client_text_visibility_extensions (...);
```
`NotificationT` and `GeneratedTextRequestT` are both sealed traits. Use the discriminator-column pattern: `kind TEXT NOT NULL` plus nullable per-subtype columns, unless a subtype's fields are too divergent to share a row.
## Schema evolution
We can't punt on this any more. With this many tables, the next field addition can't be "edit the proto." It needs a real migration mechanism.
Conventions:
- `metadata['schema_version']` is a small integer, currently `1`.
- A `Migrations` object holds an ordered `Vector[(Int, Connection => Unit)]` of migration steps.
- On `SqliteHistory.loaded`, the schema version is read; any pending migrations are applied in a single transaction; the version is bumped.
- New games start at the latest version.
- Migration steps are append-only — never edit an existing step.
This is the standard Rails / Flyway / etc. pattern, scaled down to one file.
## Read path: reconstructing `ActionResultC`
The current pattern:
```scala
val proto = ActionResult.parseFrom(payloadBlob)
val scala = ActionResultProtoConverter.fromProto(proto)
```
The new pattern:
```scala
val scala = ActionResultDbReader.read(connection, actionSeq) // assembles from action_results + child tables
```
`ActionResultDbReader.read` is one query per child table (or a single multi-result query with carefully ordered joins, though that has aggregation pitfalls). Per-table queries are simpler to write and SQLite query planning handles them well.
For bulk reads (`since`, `all`, `sinceDate`, etc.), the existing `replayFrom` helper iterates action seqs in order, and currently parses one proto per action. The new equivalent runs one batched query per child table for the relevant action_seq range, materializing rows into a map keyed by action_seq, then walks the range emitting `ActionResultC` instances assembled from the maps. This is more code than the proto version but doesn't change the algorithmic shape.
## Write path
`withNewResults` becomes:
1. INSERT into `action_results` (scalar fields).
2. For each non-empty aggregate: INSERT N rows into the corresponding child table.
All inside a single transaction. Per-action insert count is bounded by what the action does — most actions touch 03 provinces, 05 heroes, 010 changed entities total, plus 02 notifications. So the typical insert is ~520 rows. With WAL and a single per-batch transaction, this is cheap.
## Replay performance
Today's `replayFrom` parses one proto per action. Decomposed, each action requires per-child-table queries. For a 25-action replay between snapshots, that's still small absolute work — maybe 100500 rows fetched from a handful of indexed tables. Should be faster than proto parsing once the JIT warms up, but won't matter either way at typical sizes.
## Deleting the proto
> **Superseded by refined option B (see Status).** Whole-`ActionResult` `payload` is removed in 4.5h. After that it depends on per-entity classification: *refined* entities' per-entity sub-message protos + converters (e.g. `changed_hero.proto`) **do** become dead code and are deleted in 4.5i; *deep* entities' whole-entity blob protos, the leaf protos used as Tier-3 blob payloads (e.g. `event_for_hero_backstory.proto`), and `Quest` are *retained*. The original full-deletion plan below is kept for historical context.
Once `SqliteHistory` writes and reads exclusively from the new tables:
1. Delete `ActionResultProtoConverter.scala`, the `Changed{Province,Hero,Faction,Battalion}Converter.scala` files, the `Notification`/`GeneratedTextRequest`/etc. converters.
2. Delete the proto definitions: `action_result.proto`, `changed_province.proto`, `changed_hero.proto`, `changed_faction.proto`, `notification.proto`, `generated_text_request.proto`, anything else in this scope.
3. Delete the corresponding `_scala_proto` Bazel targets.
4. Gazelle should drop the unused deps from downstream targets.
This is satisfying but should land *last*, after everything's stable. The proto files are dead code at that point — keeping them around for a PR or two while we verify migration is fine.
## Sequencing
**Phase 4** (cutover) lands as planned: `GamesManager` swaps to `SqliteHistory`, save dirs get nuked, action data is the single proto-blob `payload`. Days of work, validates the lifecycle integration without conflating with schema design.
**Phase 4.5** (this design) starts after Phase 4 is verified stable. Subdivided into small PRs. **Re-planned for option B (see Status):** each entity gets the 4.5a scalar/entity-ref columns + a `<entity>_proto BLOB`; no child-table explosion. Each decomposition PR splits into `.1` (schema + backfill over existing `payload`) and `.2` (live `withNewResults` write path + read path), per the precedent set by 4.5b.
| PR | Work | State |
|---|---|---|
| 4.5a | Schema-migration scaffolding + four changed-entity top-level tables (scalar columns) | ✅ merged |
| 4.5b | `ChangedProvince`: scalar columns + `changed_province_proto BLOB` + v3 backfill (option B, #6725) | ✅ merged |
| 4.5c.1 | `ChangedHero` (**refined option B**, see Status): 4.5a scalars + loyalty/vigor Tier-2 column pairs + `newBackstoryEvents` as per-event `EventForHeroBackstory` Tier-3 blobs in `action_changed_heroes_new_backstory_events`; **no whole-entity blob**`changed_hero.proto`/`ChangedHeroConverter` become deletable, `event_for_hero_backstory.proto` retained. Schema v4 + backfill over existing `payload` (#6727) | ✅ merged |
| 4.5c.2 | `ChangedHero`: live `withNewResults` write path + read path | planned |
| 4.5d | `ChangedFaction`: same pattern (mostly scalar already; small/empty blob — see Decisions #2) | planned |
| 4.5e | `ChangedBattalion`: `changed_battalion_proto BLOB` (sole field is the contained `BattalionT`) + battalion_id index from 4.5a | planned |
| 4.5f | "New entity" vectors (`newProvinces`/`newHeroes`/`newFactions`/`newBattalions`): one table per type keyed by `(action_seq, entity_id)` + a couple of queryable handles + `<entity>_proto BLOB`. Biggest win for B — these are the deepest snapshots | planned |
| 4.5g | Notifications + generated text requests: `(action_seq, n, kind, <entity>_proto BLOB)``kind` discriminator queryable, payload blobbed (quest-hybrid shape) | planned |
| 4.5h | Top-level `ActionResultC` scalar/entity-ref columns on `action_results` (acting_hero_id, acting_faction_id, province_id, …) + **drop `payload BLOB`**. Read path = scalar columns + per-entity proto blobs (close to today's proto parse; simpler than the original per-table-join plan) | planned |
| 4.5i | Delete proto files/targets that became dead code. *Refined* entities' per-entity protos + converters (e.g. `changed_hero.proto`/`ChangedHeroConverter`) **are deleted** here. *Deep* entities' protos + leaf Tier-3 protos (e.g. `event_for_hero_backstory.proto`) + `Quest` are **retained** as blob payloads. No longer near-nothing — scope scales with how many entities went *refined* | planned |
Each PR nukes save dirs as part of its rollout (still pre-alpha). Each PR is independently reviewable and revertable. Under option B the per-PR cost is far lower than the original estimate (~110-line writer per entity, no child-table schema), so the remaining sequence is roughly **1 week**, not 34.
After 4.5: Phase 5 (idle-game eviction) and Phase 6 (final cleanup) proceed as the parent design doc lays out.
## What we gain at the end
- **Single source of truth.** Action data lives in tables; no proto representation for the things in scope.
- **Native SQL analytics.** Cross-game queries like "actions touching province X" or "all riots in year Y" are one-line SELECTs. No backfill code needed.
- **Inspectable saves.** `sqlite3 game.db` shows readable data. Debugging is normal SQL work, not "parse this binary blob."
- **Cleaner replay path.** No proto parsing during replay. The `ActionResultProtoConverter` (currently called per replayed action in `replayFrom`) goes away entirely.
- **Less code.** ~7 proto files deleted; their generated Scala targets deleted; their converter classes deleted; the dual proto/Scala representation collapses to one.
- **Schema migrations as a first-class concept.** Future field additions are SQL migrations, which is the right shape for a system that owns its storage.
## What we lose
- **The "edit one proto" workflow** for adding fields. Adding `newFooDelta: Double` to `ChangedProvinceC` is now a Scala edit + SQL migration step + converter update. More steps. Pre-alpha this is cheap; post-alpha (when migrations apply to real data) it's a careful PR but still bounded.
- **A safety net for unrecognized fields.** Proto silently keeps unknown fields on parse; SQL doesn't. We accept this because we control both ends.
## Decisions locked
1. **Refined option B is the pattern (see "Refinement" in Status; rescinds both "fully relational, no blob fallback" and the original whole-entity-blob-per-entity rule).** Per entity, three tiers: scalar/entity-ref columns (Tier 1) + typed columns for sealed value-oneofs (Tier 2) + per-row leaf-proto blobs in child tables (Tier 3). A whole-entity `<entity>_proto BLOB` is used **only for deep entities** where Tiers 23 cost more than they're worth. No child-table explosion for deep aggregates.
2. **Classify each entity: all-scalar / refined / deep (supersedes the old "is a blob needed").** *All-scalar* → Tier 1 only, no blob (likely `ChangedFaction`). *Refined* → Tiers 13, no whole-entity blob, per-entity proto deletable (`ChangedHero`). *Deep* → Tier 1 + whole-entity `<entity>_proto BLOB`, proto retained (`ChangedProvince`, new-entity snapshots). Default to *refined* for shallow entities; fall back to *deep* only when the residual is too varied for Tiers 23.
3. **What's retained vs. deleted depends on the classification.** `action_results.payload` (the whole-`ActionResult` blob) still goes away in 4.5h. *Refined* entities' per-entity sub-message protos + converters become dead code, deleted in 4.5i; only the **leaf** protos they use as Tier-3 blob payloads (e.g. `event_for_hero_backstory.proto`) are retained. *Deep* entities' protos + `Quest` stay as whole-entity blob payloads. So 4.5i is no longer near-nothing.
4. **Sub-phase ordering** as in the Sequencing table; each decomposition PR splits into `.1` (schema + backfill) and `.2` (live write + read).
5. **Phase 4 landed separately, before 4.5a** (historical, unchanged). Cutover validated the lifecycle integration with the simpler schema before decomposition began.
-291
View File
@@ -1,291 +0,0 @@
# SQLite Game History Design
## Why
The current persistence layer is file-based: action results are chunked into `.e0a` files, individual results spill to `.e0i` files for crash recovery, state snapshots live in `.e0s` files, and a `directory.e0i` index tracks chunks. This shape has produced three workarounds in recent history:
1. A `games.e0es` cache (PR #6706) to avoid re-reading the running-games list.
2. Dual-storage in `PersistedActionResult` (proto bytes + parsed proto) to amortize serialization across save flushes.
3. Lazy `gameState` (PR #6692) to avoid eager proto conversion on every replayed step, after PR #6691 fixed the `stateAfter` OOM that materialized ~4500 `PersistedActionResult` instances during a stale-LLM-replay path.
Each is a workaround for the same root cause: file-based random access is expensive, so we cache aggressively, and the cache layers are fragile (one misplaced `.map(_.gameState)` reintroduces the OOM).
The alternative — SQLite as a per-game container, with proto bytes stored as BLOB columns indexed by action sequence number — gives us random access by construction, removes the need for all three workarounds, and makes idle-game eviction cheap (rehydrate from the DB instead of reparsing files). The pattern is already in production here: `SqliteClientTextStore` does exactly this for the client text cache. We extend the same pattern to game history.
## Goal & non-goals
**In scope:**
- Per-game SQLite container (`game.db`) holding action history, state snapshots, and shardok results.
- A new `SqliteHistory` class implementing the existing `FullGameHistory` trait, drop-in replacement for `PersistedHistory` at the call site.
**Out of scope (deferred or rejected):**
- **Migration of existing saves.** Pre-alpha there are only two users and a handful of test games. Cheaper to nuke the existing save directories at cutover than to write and verify a migration importer. New games created post-cutover land in `game.db` directly; existing games are gone.
- The top-level `games.e0es` running-games registry. Stays as-is; its cache (PR #6706) already works.
- Idle-game eviction. Enabled by this work but lands in a follow-up phase after `SqliteHistory` is the authoritative path.
- A shared cross-game analytics DB. Per-game design supports ad-hoc cross-game queries via iterate-and-aggregate; build the centralized analytics DB only if/when those queries become hot.
## File layout
`game.db` lives in the existing per-game save directory, alongside `text_store.db`:
```
${EAGLE_SAVE_DIR}/${gameIdHex}/
├── game.db # NEW — action history, snapshots, shardok results
└── text_store.db # existing — client text (SqliteClientTextStore)
```
Existing `.e0a` / `.e0s` / `.e0i` / `directory.e0i` files do not coexist with `game.db`: at cutover we nuke save directories one time. There is no migration path and no legacy-file fallback.
### Why `game.db` and `text_store.db` are separate files (not tables in one DB)
It's tempting to combine them — one file per game, one connection per game, one cloud-upload story. The reason not to: **writer-lock contention.** SQLite serializes writers per-database file. `SqliteClientTextStore` writes frequently during LLM streaming (one `UPDATE` per token append on `texts.text`). `SqliteHistory` writes per-action during turn commits. Combining them means an in-flight LLM token append can block a turn commit, or vice versa. Separate DBs have separate writer locks and never contend. Subsystem ownership (each package owning its own schema, independent migration paths if the schemas evolve) is a bonus.
`Persister` integration follows the `SqliteClientTextStore` pattern: `game.db` is uploaded to / downloaded from cloud storage as a single opaque blob, keyed by the filename. On first load, if `game.db` is missing locally, try `persister.retrieveAsStream("game.db")`. If both are missing, this is a new game and we create an empty DB.
## Schema
```sql
-- Action results, one row per action_result_index.
CREATE TABLE action_results (
action_seq INTEGER PRIMARY KEY, -- 0-based, dense, never sparse
action_result_type INTEGER NOT NULL, -- denormalized for filtering
round_id INTEGER NOT NULL, -- denormalized for recentResultsForRound
date_year INTEGER, -- denormalized for sinceDate; NULL pre-game-start
date_month INTEGER, -- denormalized for sinceDate; NULL pre-game-start
payload BLOB NOT NULL -- ActionResult proto bytes
) WITHOUT ROWID;
CREATE INDEX idx_action_results_round ON action_results(round_id);
CREATE INDEX idx_action_results_date ON action_results(date_year, date_month);
-- action_seq is the PK so no index needed there.
-- State snapshots at chunk boundaries (and the starting state at seq 0).
-- boundary_action_seq is the action_seq AFTER which the snapshot reflects state.
-- The starting state lives at boundary_action_seq = 0 (state before any actions).
CREATE TABLE state_snapshots (
boundary_action_seq INTEGER PRIMARY KEY,
payload BLOB NOT NULL -- GameState proto bytes
) WITHOUT ROWID;
-- Shardok per-battle results.
CREATE TABLE shardok_results (
shardok_game_id TEXT NOT NULL,
action_seq INTEGER NOT NULL, -- 0-based within this shardok game
payload BLOB NOT NULL, -- ShardokActionResult proto bytes
PRIMARY KEY (shardok_game_id, action_seq)
) WITHOUT ROWID;
-- Shardok per-game state (one row per shardok_game_id).
CREATE TABLE shardok_state (
shardok_game_id TEXT PRIMARY KEY,
game_state BLOB NOT NULL, -- ShardokGameState proto bytes
last_eagle_round_id INTEGER NOT NULL
) WITHOUT ROWID;
-- Shardok per-player results (filtered ActionResultView per faction).
CREATE TABLE shardok_player_results (
shardok_game_id TEXT NOT NULL,
faction_id INTEGER NOT NULL,
seq INTEGER NOT NULL,
payload BLOB NOT NULL, -- ShardokActionResultView proto bytes
PRIMARY KEY (shardok_game_id, faction_id, seq)
) WITHOUT ROWID;
-- Shardok per-player available commands (latest only, keyed by game + faction).
CREATE TABLE shardok_player_commands (
shardok_game_id TEXT NOT NULL,
faction_id INTEGER NOT NULL,
payload BLOB, -- ShardokAvailableCommands proto bytes; NULL = no commands
PRIMARY KEY (shardok_game_id, faction_id)
) WITHOUT ROWID;
-- Schema version and starting state.
CREATE TABLE metadata (
key TEXT PRIMARY KEY,
value BLOB NOT NULL
);
-- Seeded with: schema_version=1
```
### Column rationale
- **`action_seq`** as `INTEGER PRIMARY KEY` (the SQLite rowid alias) — densest possible storage and no separate index; range scans for `since(start)` are O(log n + result count).
- **`WITHOUT ROWID`** on tables with synthetic keys to skip the implicit rowid column.
- **Denormalized `action_result_type`, `round_id`, `date_year`, `date_month`** — these are the predicates the existing read paths use (`recentResultsForRound`, `sinceDate`). Keeping them as columns avoids parsing the proto blob to filter.
- **No `created_at` timestamp** — wall-clock time isn't queried by the History trait, and game date (year/month) is what matters semantically.
- **`payload BLOB`** — the proto bytes are the source of truth for the structured action result. Polymorphic action types make a relational schema painful for limited gain; the denormalized columns above cover the queries we need.
- **Snapshots keyed by `boundary_action_seq`** — `stateAfter(N)` finds `MAX(boundary_action_seq) WHERE boundary_action_seq <= N`, returns that snapshot, replays forward `N - boundary_action_seq` actions. Snapshot at `0` is the game's starting state.
### Snapshot strategy
Match the existing `resultsPerSaveFile = 25` boundary: write a `state_snapshots` row every 25 actions. That gives the same replay-window cost as the current chunk-file design (`stateAfter(N)` replays at most 25 actions to reach an arbitrary point), and matches the cadence developers are already calibrated to.
Snapshots are GameState proto bytes, identical in shape to today's chunk-file `startingState`. The migration importer derives them directly from the chunk files. New games write a snapshot after every 25th `withNewResults` action.
## Connection lifecycle
Mirror `SqliteClientTextStore`:
- One `Connection` per loaded game, opened when `GamesManager` loads the game, closed when the game is evicted (future eviction work) or the server shuts down.
- `Class.forName("org.sqlite.JDBC")` + `DriverManager.getConnection("jdbc:sqlite:${path}")` on open.
- `PRAGMA journal_mode = WAL` on every connection open. WAL gives us crash-safe writes and lets readers proceed concurrently with the single writer — important because gRPC stream readers (humanPlayerClientConnectionState) query history mid-turn.
- `PRAGMA synchronous = NORMAL` (the WAL-recommended setting; durability is preserved through WAL checkpoint).
- `PRAGMA foreign_keys = ON` (defensive; we have no FKs today but cheap to enable).
- Auto-commit on by default. Transactions explicitly opened for `withNewResults` (batch of action inserts + optional snapshot) and `truncateTo` (deletes across all tables).
### Threading
The current `PersistedHistory` is an immutable case class; `withNewResults` returns a new instance. `SqliteHistory` cannot be pure-immutable (the DB is mutable state) but should present the same interface: methods that "change" the history return `this` after a successful write. The underlying `Connection` is shared.
JDBC `Connection` is not thread-safe in general; SQLite's JDBC driver serializes operations per-connection. Existing call sites already serialize writes through the `EngineApplier` flow, so single-threaded write access is preserved. Reads from gRPC stream readers can use the same connection — SQLite serializes them transparently, and WAL prevents read-write blocking.
## Read paths
How each `FullGameHistory` method maps to SQL:
| Method | Query |
|---|---|
| `count` | `SELECT COALESCE(MAX(action_seq), -1) + 1 FROM action_results` (cached as a counter after the first read) |
| `last` | `SELECT payload FROM action_results ORDER BY action_seq DESC LIMIT 1` + state from `stateAfter(count)` |
| `all` | `SELECT payload FROM action_results ORDER BY action_seq` — used by `GameAdminServiceImpl.getActionDetail` to fetch one action by index. See follow-up note below. |
| `since(start)` | `SELECT payload FROM action_results WHERE action_seq >= ? ORDER BY action_seq` |
| `sinceDate(date)` | `SELECT payload FROM action_results WHERE (date_year, date_month) >= (?, ?) ORDER BY action_seq` |
| `recentResultsForRound(round, pred)` | `SELECT payload FROM action_results WHERE round_id = ? AND action_seq > ? ORDER BY action_seq` where `?` is the cutoff matching current `recentHistory` semantics (last N actions, or all actions for the current round) |
| `stateAfter(N)` | Find latest snapshot ≤ N, replay forward via `replayApplier` (same logic as `replayScalaOnlyToState`) |
For methods that return `Vector[ActionResultWithResultingState]` (with resulting state per row): we **do not** materialize per-row gameStates. Instead, fold the actions through `replayApplier` starting from the latest snapshot ≤ start, producing the states on the fly. This matches what `formAwrs` does today, with one critical difference: no `PersistedActionResult` wrapper is allocated, and no proto-conversion is performed per step. This is the same shape as `replayScalaOnlyToState`, generalized to produce intermediate states.
### `all()` follow-up
`GameAdminServiceImpl.getActionDetail` is the one production caller. It fetches a single action by index from the full vector. Either of these is cheaper than materializing the whole history:
- Replace the call site with `history.since(index).headOption`.
- Add a new `actionAt(index): Option[ActionResultWithResultingState]` method and drop `all()` from the trait entirely.
`SqliteHistory.all` will work — it's just `SELECT * ORDER BY action_seq` — but it's expensive (materializes the full history into memory), so we should switch the admin caller in a small follow-up PR. Not blocking the SQLite work.
### `recentResultsForRound` semantics
Today this filters `recentHistory` (the in-memory tail) by `roundId`. In SQLite there's no in-memory/persisted split — all results live in the DB. The semantics shift slightly: return all results matching `round_id` after a configurable cutoff. The cutoff should match today's behavior (results since the start of the current round, or some bounded recent window). Default to "all results with the given `round_id`," which is correct as long as `round_id` uniquely identifies a round across the game's history (it does, per the current `RoundId` model).
## Write paths
### `withNewResults(newResults)`
In a transaction:
1. `INSERT INTO action_results (action_seq, action_result_type, round_id, date_year, date_month, payload) VALUES ...` — one row per new result. Use `addBatch()` for multiple.
2. For each new result whose `action_seq % 25 == 0` (snapshot boundary), `INSERT INTO state_snapshots(boundary_action_seq, payload) VALUES (?, ?)` with the GameState proto bytes.
3. Commit.
The denormalized columns (`action_result_type`, `round_id`, `date_year`, `date_month`) are extracted from the action's resulting state at insert time. They are immutable once written; if the schema interpretation changes, a migration is required.
The "individual result for crash recovery" pattern (`.e0i` files) is replaced by: the action result is durably written when the transaction commits. WAL gives us atomicity per transaction. No separate crash-recovery file is needed.
### `saveNow`
Becomes a no-op in normal operation — writes are already durable per `withNewResults` commit. We keep the method on the trait for API compatibility but the implementation just returns `this`. (We could call `PRAGMA wal_checkpoint(TRUNCATE)` here to roll the WAL into the main DB file, useful before cloud upload; defer this until we measure WAL growth.)
### `truncateTo(targetActionCount)`
In a transaction:
1. `DELETE FROM action_results WHERE action_seq >= ?`
2. `DELETE FROM state_snapshots WHERE boundary_action_seq > ?`
3. Shardok cleanup: `DELETE FROM shardok_results / shardok_state / shardok_player_results / shardok_player_commands WHERE shardok_game_id NOT IN (...)` (the set of battles still outstanding at the truncate point; mirrors `deleteOrphanedShardokFiles`).
4. Commit.
`truncateTo(0)` resets the game; everything after the starting-state snapshot is deleted.
## Shardok results
The shardok subsystem currently lives in `.e0s` files (one per battle, full per-battle state). It's loaded selectively at game-load time, only for outstanding battles (see `PersistedHistory.apply` line 248-256).
Moving to SQLite, the four shardok tables above capture:
- `shardok_results` — the per-battle result stream
- `shardok_state` — current per-battle state (one row per battle)
- `shardok_player_results` — per-faction filtered views
- `shardok_player_commands` — current available commands per faction
`withNewShardokResults` writes to all four in a transaction. `shardokCount`, `shardokGameState`, etc., become single-row indexed lookups.
The selective-load optimization disappears with SQLite: we don't proactively read anything; queries hit the DB on demand. The "load only outstanding battles" logic is replaced by "query by `shardok_game_id` when needed."
## Crash recovery
WAL replaces the `.e0i` individual-result-file mechanism. On startup:
- WAL is automatically replayed by SQLite if the previous shutdown was unclean. No application code needed.
- A successful commit means durable; an interrupted commit means rolled back. No half-written state visible.
The current "orphaned individual results on load" path (`loadIndividualResults` in `PersistedHistory.apply`) is gone.
## Cutover
No migration path. At cutover:
1. Stop the server.
2. Nuke the contents of `${EAGLE_SAVE_DIR}` (and `${EAGLE_ARCHIVE_DIR}` if there's anything there).
3. Deploy.
4. New games created post-deploy land in `game.db` directly.
Pre-alpha there are only two users and a handful of test games; a one-time nuke is cheaper than a verified migration importer. Trade-off accepted by the user explicitly.
### Testing strategy (no migration)
Without migration, we don't need equivalence-vs-`PersistedHistory` testing. The correctness gate becomes:
1. **Adapt `PersistedHistoryTest`** to run against `SqliteHistory` instead. The 890-line existing test suite covers the trait surface exhaustively. Same assertions, new implementation under test.
2. **Property-style end-to-end test**: create a synthetic game, run N batches of `withNewResults`, verify that `since(start)`, `stateAfter(N)`, `sinceDate(date)`, `recentResultsForRound`, `truncateTo`, etc., all produce expected values. Run with a deterministic random seed.
3. **Manual smoke test in dev**: start a fresh game, play several turns, restart the server, verify saves persist and replay correctly.
## Cloud-storage integration
`Persister.save(key, bytes)` and `persister.retrieveAsStream(key)` already handle local-vs-S3 transparently. `SqliteClientTextStore` integrates by treating `text_store.db` as a single binary blob: upload after writes, download before reads.
`SqliteHistory` will do the same with `game.db`:
- **On load:** if local `game.db` missing, try to download via `persister.retrieveAsStream("game.db")`. Fall back to migration from `.e0a` if both are missing.
- **On save:** after a meaningful change (turn boundary? configurable cadence?), upload the current `game.db` file to S3. Need to decide the upload trigger — too frequent and we burn S3 PUTs; too rare and crash recovery is lossy.
**Open question:** the right upload cadence. Today, chunk saves trigger cloud upload at chunk boundaries (every 25 actions). The simplest match: keep that cadence, upload `game.db` once per chunk boundary. We could be smarter (only upload if WAL has been checkpointed; only upload deltas) but those are optimizations.
## What the trait-level cutover looks like
`GamesManager` currently has roughly:
```scala
val history: FullGameHistory = PersistedHistory(gameId, persister).getOrElse(...)
```
Becomes:
```scala
val history: FullGameHistory = SqliteHistory.loaded(gameId, persister)
```
Every other consumer of `FullGameHistory` is unchanged.
## Decisions locked
1. **Per-game `game.db`** (not shared across games).
2. **`game.db` and `text_store.db` stay separate files** (not combined into one DB). Writer-lock contention is the deciding factor.
3. **No migration** of existing saves. Nuke save directories at cutover. Pre-alpha trade-off accepted by the user.
4. **No feature flag.** Cutover is unconditional. Failures are loud.
5. **Cloud upload cadence**: match the existing 25-action chunk cadence. Upload `game.db` once per snapshot boundary.
6. **`recentResultsForRound`**: return all results for the given round (no recent-window cap). The `round_id` predicate bounds the scan naturally.
7. **WAL checkpointing**: rely on SQLite's auto-checkpoint. Add explicit `PRAGMA wal_checkpoint` only if WAL file growth becomes a problem.
8. **Schema versioning**: seed `metadata` with `schema_version = 1`. Schema migration mechanism deferred until we need a v2.
9. **`history.all`**: keep on the trait for now; admin call site moved to `since(i).headOption` (or a new `actionAt(i)` method) in a small follow-up PR. Not blocking.
## Phase plan (revised)
| Phase | Work | Estimate |
|---|---|---|
| 0 | This design doc | 2-3 days (in flight) |
| 1 | `SqliteHistory` schema + write paths + tests adapted from `PersistedHistoryTest` | ~1 week |
| 2 | Read paths + full test parity | ~1 week |
| 3 | Shardok results integration | 3-5 days |
| 4 | Cutover in `GamesManager` (nuke `${EAGLE_SAVE_DIR}` as part of deploy) | 2-3 days |
| 5 | Idle-game eviction | 3-5 days |
| 6 | Post-alpha cleanup (delete `PersistedHistory`, `PersistedActionResult`, `PartialGameUtils`, the chunk-file save code, `games.e0es` cache if no longer needed) | 1-2 days |
**Total: ~3-4 weeks** (down from 5-6 — migration and equivalence-testing dropped).
The follow-up to migrate `GameAdminServiceImpl.getActionDetail` off `history.all` is a small, independent PR that can land any time.
-267
View File
@@ -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**: 31
### 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
}
}
```
-46
View File
@@ -149,52 +149,6 @@ Triggered during battles when players encounter spells, terrain, or abilities.
---
## 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 |
-322
View File
@@ -1,322 +0,0 @@
# Tutorial System Architecture
This document describes how the Unity client's tutorial system is wired together: the classes involved, the trigger catalog, the step lifecycle, and the expected first-session flow. It complements two existing docs:
- **`TUTORIAL_CONTENT.md`** — human-facing copy for tutorial steps and dialogues
- **`TUTORIAL_BATTLE_SYSTEM.md`** — the scripted first-battle scenario (Tarn vs. John Ranil)
It also overlaps slightly with the in-tree `Assets/Tutorial/TUTORIAL_PLAN.md`, which is an older implementation plan.
All paths below are relative to `src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/`.
---
## Current State (important)
There are **two parallel subsystems** that share the same trigger plumbing:
| Subsystem | Status | Lives in |
|-----------|--------|----------|
| Step-based tutorial UI (modals, overlays, hints) | **Dormant** | `Tutorial/TutorialManager.cs`, `Tutorial/UI/*`, `Tutorial/Content/*` |
| Narrative dialogue system | **Live** | `Tutorial/Dialogue/*`, `Resources/Dialogues/*.json` |
`TutorialContentDefinitions.RegisterAll()` returns early at line 17 with the comment *"Old tutorial content suppressed — replaced by narrative dialogue system."* As a result:
- `OnboardingSequence` is never assigned, so `StartOnboarding()` no-ops at the "No onboarding sequence assigned" branch.
- No contextual tutorial sequences are registered with the trigger registry.
- The trigger registry still fires events normally, but nothing on the step-UI side is listening.
- `DialogueManager` consumes those same events and matches them against scripts in `Resources/Dialogues/` (`tutorial_strategic.json`, `tutorial_battle.json`).
**In practice today, the entire active tutorial experience runs through the dialogue system.** The step-UI machinery is preserved for future use — content definitions still exist below the early return in `TutorialContentDefinitions.cs` for reference.
---
## Class Map
### Orchestration
- **`Tutorial/TutorialManager.cs`** — Singleton. Owns `OnboardingSequence`, the active step queue, the trigger registry, and the dialogue manager handle. Initialized by `EagleGameController.SetUpGame()` and `ShardokGameController.SetUpGame()`.
- **`Tutorial/TutorialState.cs`** — PlayerPrefs persistence. Tracks `OnboardingCompleted`, `OnboardingStepReached`, completed sequence IDs (HashSet for O(1) lookup), dismissed hints, and a global "tutorials disabled" preference.
- **`Tutorial/TutorialTargetRegistry.cs`** — Maps string IDs to `RectTransform`s for highlighting. Static targets are Inspector-assigned (`ProvinceInfoPanel`, `SupportField`, `CommitButton`, etc.); dynamic targets register at runtime (e.g., hero rows from `HeroesAndBattalionsPanelController`).
### Triggers
- **`Tutorial/Triggers/TutorialTriggerRegistry.cs`** (~1100 lines) — Routes game events to both the step UI (when sequences are registered) and `DialogueManager`. Maintains one-shot session flags so the same first-encounter trigger doesn't fire twice.
### Content (step UI — dormant)
- **`Tutorial/Content/TutorialStep.cs`** — Per-step record: `DisplayMode`, `CompletionType`, target path, copy, panel anchor, highlight options.
- **`Tutorial/Content/TutorialSequence.cs`** — `ScriptableObject` holding ordered `TutorialStep`s plus `IsOnboarding` flag and lifecycle callbacks.
- **`Tutorial/Content/TutorialContentDefinitions.cs`** — Static class that *would* register all sequences. Currently short-circuits before any registration.
### UI (step UI — dormant)
- **`Tutorial/UI/TutorialUIManager.cs`** — Coordinates which presenter renders each step.
- **`Tutorial/UI/TutorialCanvasBuilder.cs`** — Builds the Canvas at runtime (no prefab dependency).
- **`Tutorial/UI/TutorialModalPanel.cs`** — Full-screen blocking modal.
- **`Tutorial/UI/TutorialOverlayController.cs`** + **`TutorialOverlayBuilder.cs`** — Dimmer with highlighted cutout, gold border, pulsing animation, and adjacent tooltip text.
- **`Tutorial/UI/TutorialHintIndicator.cs`** — Stub for pulsing-dot mode.
### Dialogue (live)
- **`Tutorial/Dialogue/DialogueManager.cs`** — Loads all `Resources/Dialogues/*.json` scripts at startup, indexes by trigger ID, drives the panel.
- **`Tutorial/Dialogue/DialogueScript.cs`** / **`DialogueStep.cs`** — JSON-shaped records for scripts and steps.
- **`Tutorial/Dialogue/DialoguePanelController.cs`** — Renders the speaker headshot, body text, instruction line, optional highlight target, and Continue button.
- **Scripts:** `Assets/Resources/Dialogues/tutorial_strategic.json`, `tutorial_battle.json`.
---
## Step Lifecycle (Step-UI System)
1. **Triggered**`TutorialTriggerRegistry` raises an event (e.g., `game_started`, `battle_entered`).
2. **Queued or shown** — If no active sequence, show immediately. If an active step is hidden (`DisplayMode.None`), interrupt it. If both new and active are command tutorials, interrupt for responsiveness. Otherwise queue.
3. **Rendered**`TutorialUIManager.ShowCurrentStep()` dispatches to the modal/overlay/hint presenter based on `DisplayMode`.
4. **Awaiting completion** — One of:
- `ButtonClick` — user dismisses
- `GameEvent` — wait for a named event (e.g., `province_selected`)
- `Timer` — auto-advance after delay
- `UIInteraction` — wait for the highlighted target to be interacted with
- `Condition` — custom predicate (rare)
5. **Advance**`AdvanceStep()` moves to the next step or completes the sequence; completion writes to `TutorialState`.
Hidden steps (`DisplayMode.None`) deliberately don't block — they exist so contextual tutorials can fire while the onboarding sequence is waiting on an async event.
---
## Display Modes
| Mode | Renderer | Notes |
|------|----------|-------|
| **Modal** | `TutorialModalPanel` | Full blocking dialog with dim background |
| **Overlay** | `TutorialOverlayController` | Dimmer with cutout + tooltip near a target |
| **Tooltip** | `TutorialOverlayController` | Currently same renderer as overlay |
| **Hint** | `TutorialHintIndicator` | Pulsing dot only (stub) |
| **None** | (invisible) | Waits for a completion event |
Panels can be anchored via `TutorialPanelAnchor` (`Center` / `Left` / `Right` / `Top` / `Bottom`).
---
## Trigger Catalog
All triggers are raised via `TutorialTriggerRegistry`. Today these flow to `DialogueManager.TriggerDialogue()` and are matched against the dialogue JSON; if step-UI sequences are re-registered, they will also route there. File:line citations are for the registry unless noted; line numbers may drift.
### Bootstrap / first-session
| Trigger | Fires from | When |
|---------|-----------|------|
| `game_started` | `EagleGameController.SetUpGame()` | First time entering a tutorial game |
| `first_battle_available` | `OnModelUpdated()` ~L204 | A `RunningShardokGameModel` first appears |
| `battle_entered` | `OnBattleEntered()` ~L793 | User clicks **Battle!** |
| `tutorial_battle_ended` | `CheckTutorialBattleEnded()` ~L594 | Tutorial battle removed from running models |
| `tutorial_rebuild_support` | `CheckTutorialRebuildSupport()` ~L616 | Captured-heroes phase done |
| `tutorial_taxes_collected` | `CheckTaxesCollected()` ~L680 | New Year action with positive tax delta |
| `tutorial_in_town` | `CheckInTown()` ~L500 | Return command becomes available |
### Strategic-map contextual
| Trigger | Site | When |
|---------|------|------|
| `province_selected` | `EagleGameController.ProvinceWasSelected()` | Province click |
| `command_issued` | `EagleGameController.PostCommittedCommand()` | User commits |
| `diplomacy_available` | `CheckStrategicCommandsAvailable()` ~L243 | Diplomacy command appears |
| `weather_control_available` | `CheckStrategicCommandsAvailable()` ~L249 | Weather command appears |
| `hero_recruitment_available` | `CheckHeroRecruitmentAvailable()` ~L266 | Free heroes detected |
| `tutorial_ready_to_join` | `CheckReadyToJoinHero()` ~L479 | Free hero with `WouldJoin` |
| `profession_<X>_encountered` | `CheckProfessionTutorial()` ~L726 | First time seeing each profession |
### Strategic guidance (state-derived, conditions checked each model update)
| Trigger | Site | Condition |
|---------|------|-----------|
| `guidance_loyalty_danger` | `CheckLoyaltyDanger()` ~L339 | November + hero loyalty < 70 |
| `guidance_neighbor_danger` | `CheckNeighborDanger()` ~L383 | Hostile faction adjacent |
| `guidance_recruit_heroes` | `CheckRecruitHeroesGuidance()` ~L420 | Province with support ≥40 has free heroes |
| `guidance_expand` | `CheckExpandGuidance()` ~L449 | One stable province, hasn't expanded |
| `guidance_sworn_kinship` | `CheckSwornKinshipGuidance()` ~L549 | Good candidate or 4+ provinces |
| `tutorial_loyalty_warning` | `CheckTutorialLoyaltyWarning()` ~L634 | November + hero loyalty < 70 |
| `tutorial_support_deadline` | `CheckTutorialSupportDeadline()` ~L654 | December + province support < 40 |
### Strategic action-result triggers (fired from `OnStrategicActionResult()`)
| Trigger | Site | When |
|---------|------|------|
| `hero_stat_gained` | ~L281 | `HeroStatGained` action result |
| `hero_profession_gained` | ~L284 | `ProfessionGained` action result |
| `tutorial_faction_appears` | ~L287 | `TutorialFactionAppears` action — the Fracture Covenant landing |
| `tutorial_hero_faction_appears` | ~L290 | `TutorialHeroFactionAppears` action (no dialogue script today) |
| `tutorial_hero_departed` | ~L299 | First `HeroesDeparted` action against another player (King's hero abandons service) |
| `tutorial_hero_departed_again` | ~L302 | Second `HeroesDeparted` in a *different* month than the first |
### Tactical (battle) — abilities and spells
Combat triggers are *paced*: `_combatTutorialPending` ensures only one fires per action, with priority `archery > duel > melee > charge > fire`.
| Trigger | Site | When |
|---------|------|------|
| `shardok_placement_started` | `OnBattleEntered()` ~L796 | Battle in setup phase |
| `shardok_battle_started` | `OnBattleEntered()` ~L793 | Battle begins |
| `shardok_battle_running` | `OnTacticalCommandsAvailable()` ~L948 | Player's first turn |
| `archery_available` | `OnTacticalCommandsAvailable()` ~L990 | Archery available |
| `melee_available` | `OnTacticalCommandsAvailable()` ~L991 | Melee available |
| `duel_available` | `OnTacticalCommandsAvailable()` ~L1005 | Duel available (non-Tarn target) |
| `ability_charge_available` | `OnTacticalCommandsAvailable()` ~L1001 | Charge for Old Marek |
| `start_fire_available` | `OnTacticalCommandsAvailable()` ~L1021 | Start Fire on enemy |
| `hide_available` | `OnTacticalCommandsAvailable()` ~L985 | Hedrick can hide |
| `thunderstorm` | `OnTacticalCommandsAvailable()` ~L1056 | Weather is thunderstorm |
| `spell_lightning_available` | `OnTacticalCommandsAvailable()` ~L960 | Lightning available |
| `spell_meteor_available` | `OnTacticalCommandsAvailable()` ~L963 | Meteor available |
| `spell_holywave_available` | `OnTacticalCommandsAvailable()` ~L965 | Holy Wave available |
| `spell_raisedead_available` | `OnTacticalCommandsAvailable()` ~L969 | Raise Dead available |
| `spell_lightning_cast` / `spell_meteor_cast` / `spell_holywave_cast` / `spell_raisedead_cast` | `CheckTacticalActionType()` ~L830-838 | Spell action observed |
| `ability_charge_used` | `CheckTacticalActionType()` ~L842 | Charge attack executed |
| `terrain_fire_encountered` | `CheckTacticalActionType()` ~L864 | Fire damage / spread |
| `terrain_water_encountered` | `CheckTacticalActionType()` ~L867 | Water crossing |
| `engineer_near_enemy` | `CheckEngineerNearEnemy()` ~L1105 | John Ranil within 3 hexes of enemy |
| `tutorial_reinforcement_engineer` / `tutorial_reinforcement_paladin` | `CheckTacticalActionType()` ~L857 | Reinforcement arrival |
| `friendly_hero_captured` | `CheckHeroRemovals()` ~L919 | Friendly hero unit removed |
| `enemy_hero_captured` | `CheckHeroRemovals()` ~L928 | Enemy hero unit removed |
| `battle_action` | `ShardokGameController.OnBattleAction()` | Any action result |
| `turn_ended` | `ShardokGameController.OnTurnEnded()` ~L1137 | Battle turn ends |
| `shardok_battle_reset` | `ShardokGameController.cs:627` (fired *directly* to `DialogueManager`, bypassing the registry) | Battle retry; the registry's battle flags and the battle dialogue scripts' completed-set are reset alongside, so per-battle tutorials re-fire |
### One-shot semantics
Most contextual triggers are guarded by booleans on the registry (e.g., a `_diplomacyShown` flag). These are session-local — they don't persist via `TutorialState`, but the registry has `ResetBattleTriggerFlags()` for replays. The strategic completion list *does* persist across sessions.
---
## Highlight Targets
Steps reference UI by string ID through `TutorialTargetRegistry`:
1. Static targets are wired in the Inspector on the registry.
2. Dynamic targets register at runtime (`RegisterTarget(id, rectTransform)`).
3. Lookup falls back to `GameObject.Find()` if not registered.
Step options:
- `TargetGameObjectPath` — primary highlight
- `AdditionalHighlightTargets[]` — multiple at once
- `HighlightBoundsFromChildren` — use children's bounding box
- `HighlightPulsing` — strobe animation
Dialogue scripts use the same registry via `highlightTarget` (and `persistHighlight: true` to keep the highlight after the dialogue closes).
---
## Sequences vs. Contextual Dispatch
- **Onboarding sequence** (`IsOnboarding = true`) — Single linear sequence, started by `StartOnboarding()`. Resumes from `OnboardingStepReached`. Counts only visible steps for progress.
- **Contextual** (`IsOnboarding = false`) — Single- or multi-step, dispatched on demand by `TriggerContextualTutorial()`.
- Dispatch rules in `TriggerContextualTutorial()`:
1. No active sequence → show immediately.
2. Active step is hidden (`None`) → interrupt.
3. Both are command tutorials → interrupt.
4. Otherwise → queue.
---
## Dialogue System (Currently Live)
`DialogueManager` is a parallel system, not a step-UI subclass. It loads every `TextAsset` in `Resources/Dialogues/` at startup, parses them as `DialogueScript` objects, and indexes by `trigger`.
Script shape (see `tutorial_strategic.json`):
```json
{
"scripts": [{
"id": "tutorial_opening",
"trigger": "game_started",
"panelPosition": null,
"steps": [{
"speakerName": "Old Marek the Learned",
"speakerImagePath": "fixed/old_marek_the_learned.png",
"dialogueText": "...",
"instructionText": "Click Battle! to enter tactical combat.",
"highlightTarget": "GoToBattleButton",
"persistHighlight": true,
"highlightProvince": "Onmaa",
"completionEvent": null
}]
}]
}
```
When a registry trigger fires, `OnGameEvent()` calls `DialogueManager.TriggerDialogue(triggerId)`. If a script matches and hasn't completed, it queues (or shows immediately) and the panel renders speaker headshot + body + instruction. Steps can pause for a `completionEvent` (e.g., wait for the player to click the highlighted button), allowing the dialogue to chain across game state changes.
Dialogue panel position defaults sensibly per scene (`top` during combat) and can be overridden per script.
---
## Persistence
`TutorialState` (PlayerPrefs JSON):
- `OnboardingCompleted` (bool)
- `OnboardingStepReached` (int)
- `CompletedTutorials` (List → HashSet at load)
- `DismissedHints` (List → HashSet at load)
- `AllTutorialsDisabled` (bool)
Reset paths:
- `TutorialState.Reset()` clears everything.
- Entering a tutorial game (`GameType.Tutorial`) resets so it can be replayed.
- Settings → "Reset Tutorials" calls `TutorialManager.ResetAllProgress()`, which also calls `DialogueManager.ResetCompletedScripts()`.
`DialogueManager` tracks completed scripts in memory only — they reset whenever `TutorialState` does.
---
## Bootstrap
1. `TutorialManager` is in the scene as a singleton; `Awake` loads state and creates the trigger registry. `RegisterAll()` is called here but currently no-ops.
2. `ConnectionHandler` sets `IsTutorialGame` and `IsMultiplayerGame` when the game is created/joined.
3. `EagleGameController.SetUpGame()` calls `TutorialManager.Instance.Initialize(this, null)`. If `IsTutorialGame && !OnboardingCompleted`, it would call `StartOnboarding()` — currently a no-op because `OnboardingSequence` is null.
4. The first model update raises `game_started`. `DialogueManager` matches `tutorial_opening` from `tutorial_strategic.json` and the dialogue runs.
5. `ShardokGameController.SetUpGame()` calls `TutorialManager.Instance.Initialize(null, this)` when battle starts; raises `battle_entered` and battle-only triggers from there.
---
## Expected Tutorial Flow (today)
Driven by `tutorial_strategic.json` + `tutorial_battle.json`. Old Marek the Learned narrates almost everything.
### Narrative arc
You play **Sadar Rakon**, formerly Ikhaan Tarn's most trusted lieutenant. Tarn's behavior grew erratic, the King wouldn't listen, so Sadar broke off and raised the **Reclamation**. He's been pushed back to **Onmaa** for a last stand against Tarn's superior royal army. After surviving, the real twist arrives: a new invasion — **The Fracture Covenant**, led by a mysterious figure called *The Eagle* — lands ships on the western coast and starts taking provinces. Heroes from the King's own service mysteriously begin to desert. The Reclamation's framing pivots from "rebellion" to "the only force that can stop the actual invasion."
### Strategic-map flow (`tutorial_strategic.json`)
| # | Trigger | What happens |
|---|---------|--------------|
| 1 | `game_started` | **Opening monologue** (3 steps): Sadar's backstory, the Reclamation, last stand at Onmaa. Ends highlighting the **Battle!** button (`persistHighlight`) |
| 2 | `tutorial_battle_ended` | **Aftermath**: Tarn has *vanished*; you've captured his lieutenants. Recruit them or hold them |
| 3 | `tutorial_rebuild_support` | **Rebuild Onmaa** (3 steps): Marek frames it; **John Ranil** introduces *Improve* (engineer bonus); **Elena Fyar** introduces *Give Alms* (paladin bonus). Goal: 40 support by January |
| 4 | `tutorial_loyalty_warning` | November-ish, low-loyalty hero may leave at year end → use *Give Gold* / *Feast* |
| 5 | `tutorial_support_deadline` | December nag if support < 40: have Elena give alms now |
| 6 | `guidance_expand` (script `tutorial_expansion`) | Once support is stable: keep developing, taxes coming in January |
| 7 | `tutorial_taxes_collected` | January: gold/food collected. Now expand — March your warlord + most heroes to a neighbor, leave 12 behind |
| 8 | `tutorial_ready_to_join` | A free hero in `{provinceName}` is ready to recruit → Travel + Recruit |
| 9 | `tutorial_in_town` | When you Travel: explains Trade / Arm Troops / Divine / Recruit / Manage Prisoners; *Return* ends the turn |
| 10 | `tutorial_faction_appears` | **The twist**: Fracture Covenant lands at Ingia and Soria. *The Eagle* commands them. Tarn may be with them |
| 11 | `tutorial_hero_departed` | First King's hero abandons service — "something deeper is at work" |
| 12 | `tutorial_hero_departed_again` | Second one in a different month — "something is wrong, I can feel it" (foreshadowing) |
### Tactical battle flow (`tutorial_battle.json`)
| # | Trigger | What happens |
|---|---------|--------------|
| 1 | `shardok_placement_started` | **Placement** (3 steps): identify Tarn's units (knights, heavy infantry, dragoons), color legend (red/blue/black), placement instructions, highlight Commit |
| 2 | `shardok_battle_running` | **Turn 1 strategy** (2 steps): victory conditions for both sides, advice to hold castles and End Turn |
| 3 | `archery_available` | First archery opportunity → purple outlines, longbows vs. armor |
| 4 | `melee_available` | First melee opportunity → right-click adjacent enemy |
| 5 | `ability_charge_available` | Charge mechanics (Marek hams it up: "old scholar without a horse") |
| 6 | `start_fire_available` | Start Fire on enemy hex, fire spread mechanics |
| 7 | `thunderstorm` | Weather: archery disabled, fires extinguished |
| 8 | `tutorial_reinforcement_paladin` | **Elena Fyar arrives** mid-battle (2-step exchange) → Paladin profession intro (Holy Wave) |
| 9 | `tutorial_reinforcement_engineer` | **John Ranil arrives** (2-step exchange) → Engineer profession intro |
| 10 | `engineer_near_enemy` | Once Ranil is close to enemy: Fortify + Reduce (siege bombardment) |
| 11 | `duel_available` | Champion duel mechanics (Marek warns *not* to duel Tarn himself) |
| 12 | `hide_available` | Hedrick the Hedge-merchant: forest/swamp Hide + ambush |
| 13 | `enemy_hero_captured` / `friendly_hero_captured` | Capture mechanics, with `{heroName}` substitution |
| 14 | `shardok_battle_reset` | If you lose and replay: Marek "had the strangest sensation… as though we'd already fought this battle, and lost" — fourth-wall-adjacent retry framing |
Most remaining "Tutorial / first-session onboarding" items in `SMALL_EAGLE_TODO.md` (Shardok tutorial, narrative hook, first-session goal, early small victory, guided first scenario vs. sandbox) map to *adding new dialogue scripts* against existing trigger IDs, or — if the step-UI is revived — to populating `TutorialContentDefinitions.RegisterAll()` and removing the early return.
---
## Notable Behaviors and Gotchas
- **`IsTutorialGame` gates everything.** Outside tutorial games, both subsystems short-circuit early.
- **Combat tutorials are deliberately paced.** Don't expect every available-ability trigger to fire on its first eligible turn — the registry intentionally spaces them.
- **One-shot flags are session-local.** A trigger that "already fired" will not re-fire even if `TutorialState` is reset, until the registry is recreated.
- **`TutorialTargetRegistry` lookups silently fall back to `GameObject.Find`.** Typos in target IDs will render with no highlight rather than throwing.
- **`TutorialContentDefinitions.RegisterAll()` returning early is load-bearing.** If the step UI is revived without auditing trigger overlap, dialogue and step-UI tutorials may double-fire on the same trigger.
- **`Tutorial/TUTORIAL_PLAN.md`** in the Unity project is an older implementation plan and partially overlaps this doc.
-19
View File
@@ -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.
-173
View File
@@ -1,173 +0,0 @@
# URP Migration Plan
## Why
Unity is removing Built-In Render Pipeline (BiRP) support after Unity 6.7. We're currently on Unity 6.4. Each Unity minor version ships roughly quarterly, giving us a few release cycles before BiRP is dropped. This document lays out the migration plan.
## Strategy: Long-Lived Feature Branch
All URP conversion work happens on a **long-lived `urp-migration` branch**. Main stays fully functional on BiRP throughout the migration.
- **Main branch**: Continues using BiRP. All gameplay, AI, and server development proceeds normally.
- **URP branch**: Accumulates rendering pipeline changes across all phases.
- **Regular rebasing**: Periodically rebase/merge `main` into the URP branch to stay current. Shader and material changes rarely conflict with gameplay code, so merge conflicts should be manageable.
- **Merge to main**: Only when everything renders correctly and visual QA passes.
This avoids the "everything is pink" problem of switching the pipeline on main before shaders are converted.
## Current State
| Category | Count | Notes |
|---|---|---|
| Shaders | 47 | 7 custom Eagle, 1 hex mesh, 20 Polytope, 13 TextMesh Pro, 6 other third-party |
| Materials | 325 | Mix of Standard shader, custom, and third-party |
| ShaderGraph files | 4 | All TextMesh Pro (URP + HDRP variants already exist) |
| Scenes with baked lighting | 5 | Connection, Eagle, Shardok, Shared, Map Editor |
| Post-processing | 1 profile | PPP_Orc.asset (Post Processing Stack v2, FXAA/TAA, AO) |
| C# rendering scripts | 16 | Material/shader property manipulation only |
| Rendering path | Forward | Matches URP default |
### Positive Findings
- **No OnRenderImage, Graphics.Blit, CommandBuffer, or GL.\* usage** in C# code
- Forward rendering already in use (matches URP default)
- TextMesh Pro already has URP ShaderGraph variants in the project
- No custom render passes or ScriptableRenderFeatures
- Shader property manipulation (SetTexture, SetFloat, SetColor) is URP-compatible
### Known Risk Areas
- **GrabPass shaders** (HeatShimmerShader, PT_Water_Shader) have no direct URP equivalent
- **ProvinceMapShader** is complex (province ID lookup, border rendering, ocean animation, faction highlighting, UI clipping)
- **Surface shaders** (`#pragma surface surf Standard`) must be rewritten as HLSL or ShaderGraph
- **Polytope Studio shaders** (20 shaders) have no vendor-provided URP variants
- **Post Processing Stack v2** must be replaced with URP's integrated Volume system
- **Tessellation** (PT_Water_Shader) is not natively supported in URP
## Shader Inventory
### Custom Eagle Shaders (7) -- HIGH PRIORITY
| Shader | Complexity | Key Issues |
|---|---|---|
| ProvinceMapShader | High | Province ID texture lookup, border rendering, ocean animation, faction highlighting, `UnityUI.cginc` dependency, stencil/clipping |
| ProvinceWeatherMapShader | Medium | Weather overlay rendering |
| ProvinceWeatherShader | Medium | Province weather effects |
| ProvinceParticleShader | Low | Custom particle rendering |
| HeatShimmerShader | High | **GrabPass** for screen distortion, province masking |
| ClipRectParticleUnlit | Low | UI-clipped particle shader |
| maskShader | Low | UI masking |
### Hex Mesh Shader (1) -- MODERATE PRIORITY
Uses `#pragma surface surf Standard` with GPU instancing. Straightforward conversion to URP Lit or ShaderGraph.
### Third-Party Shaders (39)
| Source | Count | Complexity | Notes |
|---|---|---|---|
| Polytope Studio | 20 | Moderate-High | PBR, Toon, vegetation (custom lighting), water (**GrabPass + tessellation**). No vendor URP pack available. |
| TextMesh Pro | 13 | Low | URP ShaderGraph variants already exist in project |
| RRFreelance | 3 | Low | Standard surface shaders, direct conversion |
| Clown.fat | 2 | Moderate | Custom ToonRamp lighting model |
| GUI Pro Kit | 1 | Low | Hidden particle shader |
### GrabPass Shaders (Require Special Handling)
GrabPass does not exist in URP. These must be reimplemented using `ScriptableRenderPass` + `Renderer Features` or Blit-based alternatives:
1. **HeatShimmerShader** -- Screen distortion effect (currently has a shimmer-disabled TODO, may be deprioritized)
2. **PT_Water_Shader** -- Water refraction/transparency (PT_Water_Shader_WebGl exists without GrabPass as a reference)
## Phased Approach
### Phase 1: Pipeline Setup + Auto-Conversion (Days 1-3)
- Install URP package
- Create URP Pipeline Asset and Renderer Asset
- Configure basic pipeline settings (forward rendering, shadow settings)
- Run Unity's **Render Pipeline Converter** (Edit > Rendering > Render Pipeline Converter)
- Auto-converts Standard shader materials and some built-in shaders
- Handles a significant portion of the 325 materials
- Will NOT touch custom shaders
- Create a test scene to validate basic rendering
- Migrate viewport clipping system (`RendererViewportClipper`, `ViewportClipper`, `PopupClipper`) to use URP-compatible global shader properties
### Phase 2: Custom Eagle Shaders (Weeks 1-3)
This is the critical path. Without these, the game is unplayable.
**Week 1-2: ProvinceMapShader**
- Convert from BiRP Cg/HLSL to URP HLSL
- Replace `UnityCG.cginc` includes with `Core.hlsl` / `Common.hlsl`
- Replace `UnityUI.cginc` with custom URP-compatible UI clipping
- Preserve province ID texture lookup, border rendering, ocean animation, faction highlighting
- Validate stencil operations and render queue ordering
**Week 2-3: Remaining Eagle shaders**
- ProvinceWeatherMapShader + ProvinceWeatherShader
- ProvinceParticleShader + ClipRectParticleUnlit
- maskShader
- Hex Mesh Shader (convert surface shader to URP Lit)
- HeatShimmerShader (reimplement without GrabPass, or defer if shimmer remains disabled)
### Phase 3: Third-Party Shaders (Weeks 4-6)
**Polytope Studio (20 shaders)**
- Convert PBR shaders (Armors, NPC, Weapons, Props, Rock) from surface shaders to URP Lit / ShaderGraph
- Convert Toon shaders to custom URP shader or ShaderGraph with custom lighting
- Convert vegetation shaders (custom `StandardCustom` lighting) to ShaderGraph
- PT_Water_Shader: Reimplement without GrabPass and tessellation (use PT_Water_Shader_WebGl as reference for non-GrabPass approach)
**Other third-party**
- Clown.fat ToonRamp shaders: Convert custom lighting model to ShaderGraph
- RRFreelance shaders: Direct Standard-to-URP-Lit conversion
- GUI Pro Kit particle shader: Convert to URP particle shader
### Phase 4: Materials, Lighting & Post-Processing (Weeks 7-8)
**Materials**
- Batch-update any remaining materials not handled by the auto-converter
- Verify all 325 materials render correctly
- Fix any visual differences from lighting model changes
**Lighting**
- Rebake lightmaps for all 5 scenes (Connection, Eagle, Shardok, Shared, Map Editor)
- Configure URP shadow cascade settings to match current quality levels
- Verify HDR rendering
**Post-Processing**
- Remove Post Processing Stack v2 dependency (`com.unity.postprocessing: 3.5.1`)
- Replace with URP integrated Volume system
- Recreate PPP_Orc effects (FXAA/TAA, Ambient Occlusion) using URP Volume overrides
### Phase 5: Testing & Validation (Weeks 9-11)
- Visual QA across all 6 scenes
- Verify beast/character rendering (vertex colors, animations)
- Verify weather effects (blizzard, drought, flood)
- Verify particle systems (fire & explosion effects, UI particles)
- Verify UI rendering (GUI Pro Kit, Modern UI Pack, TextMesh Pro)
- Performance profiling (URP has different performance characteristics)
- Test on target platforms
- Bug fixes and visual polish
## Effort Estimates
| Phase | Best Case | Realistic | Worst Case |
|---|---|---|---|
| 1. Pipeline Setup | 1-2 days | 2-3 days | 3-5 days |
| 2. Custom Eagle Shaders | 1.5 weeks | 2-3 weeks | 3-4 weeks |
| 3. Third-Party Shaders | 1.5 weeks | 2-3 weeks | 3-4 weeks |
| 4. Materials & Lighting | 3-5 days | 1-1.5 weeks | 2 weeks |
| 5. Testing & Validation | 1 week | 1.5-2 weeks | 2-3 weeks |
| **Total** | **6-8 weeks** | **8-12 weeks** | **12-16 weeks** |
The biggest variable is Polytope Studio shader conversion. If vendor URP packs become available, Phase 3 shrinks significantly.
## Key Reminders
- **Don't leave GrabPass rewrites and ProvinceMapShader for the end** -- they're the riskiest pieces and should be tackled early.
- The migration window (~1 year) is comfortable. Spreading the work across this window is fine, but front-load the hard shader work.
- C# code changes should be minimal -- the codebase avoids direct rendering API usage.
- `UnityUI.cginc` has no direct URP equivalent. Custom implementation will be needed for UI clipping in shaders.
-181
View File
@@ -1,181 +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 most quests via `FulfillQuestsCommandSelector`, which is invoked by `MidGameAIClient.chosenFulfillEasyQuestsCommand`.
Important scope limits:
- The selector only considers quests from unaffiliated heroes in provinces owned by the acting faction.
- It further filters to provinces ruled by that faction's leader.
- It only emits a command when the corresponding command is currently available.
- Handler order is fixed. The first handler that produces a valid command wins.
## Quests the AI Actively Completes
This section lists quests with direct `QuestCommandChooser` handlers in `FulfillQuestsCommandSelector`.
### Diplomacy Quests
| Quest | Handler | Conditions |
|-------|---------|------------|
| `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 |
| `AllianceQuest` | `AllianceQuestCommandChooser` | Target faction must meet trust conditions for alliance and not already be in an alliance |
| `BetrayAllyQuest` | `BetrayAllyQuestCommandChooser` | Break alliance with target faction. Only if factions don't share a border. Sends weakest non-leader hero. |
### 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 |
| `TotalDevelopmentQuest` | `TotalDevelopmentQuestCommandChooser` | Improve the lowest stat in the target province |
### 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 |
| `SpendOnFeastsInProvinceQuest` | `SpendOnFeastsQuestCommandChooser` | Spend gold on feasts in the quest holder's province |
| `SpendOnFeastsAcrossRealmQuest` | `SpendOnFeastsQuestCommandChooser` | Spend gold on the most expensive currently available feast |
| `SendSuppliesQuest` | `SendSuppliesQuestCommandChooser` | Send food to the target province |
### Prisoner Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `ReleasePrisonerQuest` | `ReleasePrisonerQuestCommandChooser` | Release a specific prisoner |
| `ExilePrisonerQuest` | `ExilePrisonerQuestCommandChooser` | Exile a specific prisoner |
| `ExecutePrisonerQuest` | `ExecutePrisonerQuestCommandChooser` | Execute a specific prisoner |
| `ReturnPrisonerQuest` | `ReturnPrisonerQuestCommandChooser` | Return a prisoner to their faction |
| `ReleaseAllPrisonersQuest` | `ReleaseAllPrisonersQuestCommandChooser` | Release all prisoners |
### Province Order Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `DevelopProvincesQuest` | `DevelopProvincesQuestCommandChooser` | Switches provinces to Develop order. Prefers provinces without hostile neighbors. |
| `MobilizeProvincesQuest` | `MobilizeProvincesQuestCommandChooser` | Switches provinces to Mobilize order. Prefers provinces with hostile neighbors. Skipped if a DevelopProvincesQuest also exists (conflict avoidance). |
### Weather/Epidemic Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `StartEpidemicQuest` | `StartEpidemicQuestCommandChooser` | Start an epidemic in a province. Skipped if the target province belongs to the acting faction. |
| `StartBlizzardQuest` | `ControlWeatherQuestCommandChooser` | Start a blizzard via ControlWeather command. Skipped if the target province belongs to the acting faction. |
| `StartDroughtQuest` | `ControlWeatherQuestCommandChooser` | Start a drought via ControlWeather command. Skipped if the target province belongs to the acting faction. |
### Military Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `GrandArmyQuest` | `GrandArmyQuestCommandChooser` | Uses OrganizeTroops to top off existing battalions and hire Light Infantry. Only executes if resources are sufficient to fully meet the troop target. |
| `BattalionDiversityQuest` | `BattalionDiversityQuestCommandChooser` | Hires one battalion of a missing type to reach 3+ distinct types. Only attempts if the province already has 2+ existing types, has sufficient development (`meetsRequirements`), and gold/food surplus. |
| `UpgradeBattalionQuest` | `UpgradeBattalionQuestCommandChooser` | Walks a priority tree per turn: arm-completes (with optional travel and/or food sale setup) → train-completes → march in a pre-qualified neighbor battalion → organize/top-off of the target type → march in an unqualified neighbor battalion → develop Economy/Agriculture → train progress → develop Infrastructure → arm progress. Only toggles Travel when it directly enables an arm-completes finisher. |
### Reconnaissance Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `ReconSpecificProvincesQuest` | `ReconSpecificProvincesQuestCommandChooser` | Recon specific target provinces. Prefers not-yet-reconned targets. |
| `ReconProvincesQuest` | `ReconProvincesQuestCommandChooser` | Issues a Recon command to reconnoiter a province |
### Beast Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `FightBeastsAloneQuest` | `FightBeastsAloneQuestCommandChooser` | Send an expendable non-leader hero to fight beasts without a battalion. Hero must have power at most `MaxExpendableHeroPowerRatio` (default 0.75) times the quest-giving hero's power. Picks the weakest eligible hero. |
### Special Event Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `ApprehendOutlawQuest` | `ApprehendOutlawQuestCommandChooser` | Apprehend a specific outlaw hero when present in the province |
### Other Quests
| Quest | Handler | Conditions |
|-------|---------|------------|
| `RestProvinceQuest` | `RestProvinceQuestCommandChooser` | Use the Rest command in a specific province |
| `SwearBrotherhoodWithHeroQuest` | `SwearBrotherhoodQuestCommandChooser` | Swear brotherhood with a specific hero |
| `DismissSpecificVassalQuest` | `DismissSpecificVassalCommandChooser` | Only if province has more than 2 heroes AND the unaffiliated hero's power >= target hero's power * `RequiredPowerMultiplierForDismiss` |
## Quests the AI Handles Indirectly
These are not in `FulfillQuestsCommandSelector`, but other AI command-selection code can still bias toward completing them.
| Quest | Handler | Notes |
|-------|---------|-------|
| `SuppressRiotByForceQuest` | `CommandChoiceHelpers.handleRiotSelectedCommand` | When this quest exists and the faction has battalions, the AI prefers CrackDown over Give when handling riots. |
## Quests the AI Does Not Yet Attempt to Complete
The following quests have no handler and must be completed naturally through gameplay. They are listed in rough priority order for future implementation.
### Planned for Implementation
None currently planned.
### Not Planned
These quests are too situational, passive, or risky to actively pursue. They may be completed naturally through gameplay.
- `BorderSecurityQuest` - Have troops in a border province. Involves taking provinces; better left to organic expansion.
- `DefeatFactionQuest` - Defeat a specific faction. Too risky to pursue proactively.
- `SpecificExpansionQuest` - Conquer a specific province. Too risky.
- `ExpandToProvincesQuest` - Expand to control a certain number of provinces. Too risky.
- `WinBattleOutnumberedQuest` - Win a battle while outnumbered. Can't reliably engineer.
- `WinBattlesQuest` - Win a number of battles. Passive.
- `RescueImprisonedLeaderQuest` - Rescue an imprisoned leader. Complex multi-step.
- `WealthQuest` - Accumulate gold and food. Happens passively.
- `RepairDevastationQuest` - Repair devastation. Happens passively.
## 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. TruceWithFaction
2. Improve (Agriculture/Economy/Infrastructure)
3. TotalDevelopment
4. AlmsToProvince
5. GiveToHeroesInProvince
6. AlmsAcrossRealm
7. GiveToHeroesAcrossRealm
8. TruceCount
9. DismissSpecificVassal
10. ReleasePrisoner
11. ExilePrisoner
12. ExecutePrisoner
13. ReturnPrisoner
14. ReleaseAllPrisoners
15. ApprehendOutlaw
16. SpendOnFeastsInProvince / SpendOnFeastsAcrossRealm
17. RestProvince
18. DevelopProvinces
19. MobilizeProvinces
20. SendSupplies
21. StartEpidemic
22. ControlWeather (StartBlizzard/StartDrought)
23. GrandArmy
24. FightBeastsAlone
25. BattalionDiversity
26. UpgradeBattalion
27. ReconSpecificProvinces
28. ReconProvinces
29. SwearBrotherhood
30. Alliance
31. BetrayAlly
@@ -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.
-93
View File
@@ -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 `Assets/Scenes/Eagle.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
+310
View File
@@ -0,0 +1,310 @@
# Scala 3 Migration: Reflection Issues Found
This document catalogs all reflection-related problems discovered during the Scala 2.13.16 → Scala 3.7.2 migration of the Eagle0 codebase.
## Summary
The migration revealed several categories of reflection issues that needed to be addressed for Scala 3 compatibility:
1. **Scala 2 Runtime Reflection API** - No longer available in Scala 3
2. **Settings System Reflection** - Custom reflection for loading settings singletons
3. **json4s Automatic Case Class Extraction** - Uses reflection that fails with Scala 3 metaprogramming classes
4. **ScalaTest Exception Handling** - Syntax changes affecting exception variable binding
## 1. Scala 2 Runtime Reflection (FIXED)
### Issue
Tests using `scala.reflect.runtime.universe` fail because this reflection API doesn't exist in Scala 3.
### Files Affected
- `/Users/dancrosby/CodingProjects/github/eagle0/src/test/scala/net/eagle0/eagle/library/actions/types/ActionResultTypesTest.scala`
### Error
```scala
import scala.reflect.runtime.universe // Not available in Scala 3
```
### Solution Applied
**Deleted the test entirely** as it was redundant. The test was verifying that auto-generated Scala objects (created by Bazel from proto enum values) matched their source proto values - something already guaranteed by the build system. Since the objects are generated directly from the proto definitions, this test provided no value.
**Files deleted:**
- `src/test/scala/net/eagle0/eagle/library/actions/types/ActionResultTypesTest.scala`
## 2. Settings System Reflection (FIXED)
### Issue
Custom `SettingsLoader` class used reflection to access Scala object singletons, but the reflection pattern changed between Scala 2 and Scala 3.
### Files Affected
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/library/settings/loaders/SettingsLoader.scala`
### Error
```
java.lang.NoSuchMethodException: net.eagle0.eagle.library.settings.ApprehendOutlawVigorCost$.MODULE$
```
### Root Cause
In Scala 2, singleton objects are accessed via `ClassName$.MODULE$()`, but in Scala 3, they're accessed directly via `ClassName$` field. Additionally, `scala.reflect.runtime.universe` is not available in Scala 3.
### Solution Applied
**Completely eliminated reflection** by auto-generating the entire `SettingsLoader.scala` file from BUILD.bazel definitions:
1. **Created generator**: `src/main/go/net/eagle0/build/settings_loader_generator/settings_loader_generator.go` - parses BUILD.bazel and generates complete SettingsLoader.scala with pattern matching for all 272 settings
2. **Added genrule**: In `src/main/scala/net/eagle0/eagle/library/settings/loaders/BUILD.bazel`:
```python
genrule(
name = "settings_loader_src",
srcs = ["//src/main/scala/net/eagle0/eagle/library/settings:BUILD.bazel"],
outs = ["SettingsLoader.scala"],
cmd = "$(location //src/main/go/net/eagle0/build/settings_loader_generator) $(location //src/main/scala/net/eagle0/eagle/library/settings:BUILD.bazel) > $@",
tools = ["//src/main/go/net/eagle0/build/settings_loader_generator"],
)
```
3. **Result**: SettingsLoader now uses compile-time pattern matching instead of reflection:
```scala
private def settingObjectForKey(key: String): Any = key match {
case "ActionVigorCost" => ActionVigorCost
case "BaseFoodBuyPrice" => BaseFoodBuyPrice
// ... all 272 settings auto-generated
case _ => throw NoSuchSettingException(key)
}
```
### Benefits
- **No reflection** - Completely Scala 3 compatible
- **Maintainable** - New settings automatically included when added to BUILD.bazel
- **Performance** - Pattern matching is faster than reflection
- **Type-safe** - Compile-time checking of all settings
## 3. json4s Reflection Issues (MULTIPLE LOCATIONS)
### 3.1 EagleServiceImpl JSON Serialization (FIXED)
#### Files Affected
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/service/EagleServiceImpl.scala`
#### Error
```
java.lang.NoClassDefFoundError: scala/quoted/staging/package$
```
#### Root Cause
json4s automatic case class serialization uses reflection that tries to access Scala 3 metaprogramming classes (`scala.quoted.staging.package$`) which aren't available at runtime.
#### Solution Applied
Replaced automatic json4s serialization with ScalaPB's built-in JSON support:
```scala
// Old (reflection-based):
// implicit val formats: DefaultFormats.type = DefaultFormats
// write(actionResultView)
// New (ScalaPB JSON support):
import scalapb.json4s.JsonFormat
JsonFormat.toJsonString(actionResultView.toProto)
```
### 3.2 ShardokMapInfo JSON Parsing (FIXED)
#### Files Affected
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/library/util/ShardokMapInfo.scala` (Line 44)
#### Error
```
java.lang.NoClassDefFoundError: scala/quoted/staging/package$
at org.json4s.reflect.ScalaSigReader$.readConstructor(ScalaSigReader.scala:42)
```
#### Root Cause
The line `val extracted = parsedJson.extract[List[ShardokMapInfo]]` uses json4s automatic case class extraction which relies on reflection.
#### Solution Applied
Replaced automatic extraction with manual JSON parsing:
```scala
// OLD (reflection-based):
val extracted = parsedJson.extract[List[ShardokMapInfo]]
// NEW (manual parsing, no reflection):
val extracted = parsedJson match {
case JArray(items) => items.map { item =>
val name = (item \ "name").extract[String]
val castleCount = (item \ "castleCount").extract[Int]
val positions = (item \ "positions").extract[Map[Int, Int]]
ShardokMapInfo(name, castleCount, positions)
}
case _ => throw new Exception("Expected JSON array for map info")
}
```
#### Testing
The fix was verified - `attack_command_chooser_test` now passes successfully.
### 3.3 HeroNameFetcher JSON Parsing (FIXED)
#### Files Affected
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/library/util/hero_name_fetcher/HeroNameFetcher.scala`
#### Issue
Case class extraction `parsedJson.extract[ResponseBody]` uses reflection that may fail in Scala 3.
#### Solution Applied
Replaced automatic case class extraction with manual JSON parsing:
```scala
// OLD (reflection-based):
val parsedJson = json.parse(src.getLines().mkString)
parsedJson.extract[ResponseBody]
// NEW (manual parsing, no reflection):
parsedJson \ "names" match {
case JArray(nameArray) =>
nameArray.map { nameObj =>
val id = (nameObj \ "id").extract[String]
val name = (nameObj \ "name").extract[String]
NameResponse(id, name)
}.toVector
case _ => throw new Exception("Expected 'names' array in response")
}
```
#### Testing
The fix was verified - HeroNameFetcher now builds successfully without reflection.
### 3.4 Other json4s Usage Analysis
#### Files with json4s extraction:
- **✅ SAFE**: OpenAI/Claude Services - Only extract simple types (`String`, `Int`) - no reflection
- **✅ FIXED**: `HeroNameFetcher.scala` - Replaced `extract[ResponseBody]` with manual parsing (no reflection)
- **⚠️ POTENTIAL ISSUES** (not currently causing failures but should be monitored):
- `JsonUtils.scala`: `extract[Map[String, Vector[String]]]` - complex type extraction
- `HexMapJsonUtils.scala`: `extract[List[JObject]]` - may be problematic
#### Recommendation
Apply the same manual parsing pattern to remaining case class extractions if they cause runtime failures during Scala 3 migration.
## 4. ScalaTest Exception Handling Syntax (FIXED)
### Issue
Scala 3 changed how exception variables are bound in ScalaTest's `the[Exception] thrownBy {...}` construct.
### Files Affected
**70+ test files** across the codebase using exception testing patterns.
### Error Pattern
```
Not found: ex
```
### Root Cause
In Scala 2: `the[Exception] thrownBy { ... }` automatically creates an `ex` variable.
In Scala 3: The exception variable must be explicitly bound.
### Solution Applied
Added explicit variable binding across all affected test files:
```scala
// Old Scala 2 syntax:
the[EagleCommandException] thrownBy {
// test code
}
ex.getMessage shouldBe "expected message"
// New Scala 3 syntax:
val ex = the[EagleCommandException] thrownBy {
// test code
}
ex.getMessage shouldBe "expected message"
```
### Script Used
Created and ran a systematic fix script that processed 70+ files:
```bash
# Pattern to find and fix exception handling
find . -name "*.scala" -exec sed -i '' 's/the\[\([^]]*\)\] thrownBy {/val ex = the[\1] thrownBy {/g' {} \;
```
## 5. ScalaTest Import Changes (FIXED)
### Issue
Scala 3 requires different imports for ScalaTest matchers.
### Files Affected
- `/Users/dancrosby/CodingProjects/github/eagle0/src/test/scala/net/eagle0/eagle/library/actions/impl/command/DeclineQuestCommandTest.scala`
### Error
```
value convertToAnyShouldWrapper is not a member of object org.scalatest.matchers.should.Matchers
```
### Solution Applied
Changed from specific imports to wildcard import:
```scala
// Old:
import org.scalatest.matchers.should.Matchers.{convertToAnyShouldWrapper, the}
// New:
import org.scalatest.matchers.should.Matchers.*
```
## 6. Mock Framework Issues (FIXED)
### Issue
ScalaMock had type inference issues with Scala 3 for classes with constructor parameters.
### Files Affected
- `/Users/dancrosby/CodingProjects/github/eagle0/src/test/scala/net/eagle0/eagle/library/EngineImplTest.scala`
### Error
```
Found: Vector
Required: Vector[net.eagle0.eagle.library.util.hero_generator.hero_with_name.HeroWithName]
```
### Root Cause
Mock framework couldn't properly infer types for `mock[HeroGenerator]` where `HeroGenerator` has constructor parameters.
### Solution Applied
The user updated to a newer ScalaMock version that fixed this issue, plus added some missing Bazel dependencies:
```scala
// Also needed to add missing dependency:
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution"
```
## Migration Status
### ✅ COMPLETED
- [x] Scala 2 runtime reflection removal
- [x] Settings system reflection compatibility
- [x] EagleServiceImpl json4s → ScalaPB JSON
- [x] ScalaTest exception handling syntax (70+ files)
- [x] ScalaTest import changes
- [x] Mock framework issues (via ScalaMock update)
- [x] All test compilation issues resolved
### ⚠️ REMAINING
- [ ] **Potential json4s case class extractions** - May cause runtime failures (JsonUtils, HexMapJsonUtils) - currently no test failures reported
### 📊 PROGRESS
- **Tests passing**: All identified runtime failures resolved
- **Build failures**: 0 (all tests now compile)
- **Runtime failures**: 0 (critical ShardokMapInfo issue resolved)
## Recommendations
1. **✅ COMPLETED**: ShardokMapInfo json4s reflection issue resolved with manual parsing
2. **Monitor remaining json4s usage**: Watch for runtime failures in HeroNameFetcher, JsonUtils, and HexMapJsonUtils during full Scala 3 migration
3. **Consider ScalaPB for new JSON needs**: For new functionality, prefer ScalaPB's JSON support to avoid reflection entirely
4. **Apply manual parsing pattern**: If other json4s case class extractions cause runtime failures, use the same manual parsing approach demonstrated in ShardokMapInfo
## Key Learnings
- **Scala 3 reflection changes**: Major differences in singleton object access patterns
- **json4s compatibility**: Automatic case class extraction doesn't work well with Scala 3 metaprogramming
- **ScalaPB advantage**: Using ScalaPB's JSON support avoids reflection issues entirely
- **Systematic approach**: Many issues followed patterns that could be fixed with scripts across multiple files
-5
View File
@@ -1,5 +0,0 @@
# Export BUILD files for use with http_archive build_file attribute
exports_files([
"BUILD.gtl",
"BUILD.llvm_mingw",
])
-48
View File
@@ -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++",
])
-2
View File
@@ -11,8 +11,6 @@ require (
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
)
-4
View File
@@ -41,10 +41,6 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6
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=
+162 -1379
View File
File diff suppressed because it is too large Load Diff
+2 -63
View File
@@ -39,7 +39,7 @@ http {
server {
listen 80;
listen [::]:80;
server_name prod.eagle0.net eagle0.net;
server_name prod.eagle0.net;
# Let's Encrypt challenge
location /.well-known/acme-challenge/ {
@@ -57,7 +57,7 @@ http {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name prod.eagle0.net eagle0.net;
server_name prod.eagle0.net;
# SSL certificates (managed by certbot)
ssl_certificate /etc/letsencrypt/live/prod.eagle0.net/fullchain.pem;
@@ -112,20 +112,6 @@ http {
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;
@@ -133,13 +119,6 @@ http {
proxy_set_header X-Real-IP $remote_addr;
}
# Credits/attributions page (proxied to Go auth service)
location /credits {
proxy_pass http://auth:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Health check endpoint
location /health {
access_log off;
@@ -269,44 +248,4 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# HTTP server for Accounts Console (Let's Encrypt + redirect)
server {
listen 80;
listen [::]:80;
server_name accounts.prod.eagle0.net accounts.eagle0.net;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS server for Accounts Console (user self-service portal)
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name accounts.prod.eagle0.net accounts.eagle0.net;
ssl_certificate /etc/letsencrypt/live/accounts.eagle0.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/accounts.eagle0.net/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
location / {
proxy_pass http://admin:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
-3
View File
@@ -8,6 +8,3 @@ ZIP_LOCATION=$(bazel cquery --config=mactools --output=files @net_eagle0_unity_g
/usr/bin/unzip -o $ZIP_LOCATION -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
+4 -64
View File
@@ -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/
-31
View File
@@ -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/"
-150
View File
@@ -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
+10 -63
View File
@@ -5,58 +5,24 @@
#
# Environment variables:
# SIGNING_IDENTITY - The signing identity (default: "Developer ID Application")
# KEYCHAIN_PASSWORD - Password to unlock the keychain (optional, CI only)
# KEYCHAIN_NAME - Name of the keychain containing the signing certificate (optional, CI only)
# When set, looks for certificate in this specific keychain.
# When not set, searches all keychains (local dev mode).
# KEYCHAIN_PASSWORD - Password to unlock the build keychain (optional)
set -euxo pipefail
APP_PATH="$1"
ENTITLEMENTS_PATH="${2:-}"
SIGNING_IDENTITY="${SIGNING_IDENTITY:-Developer ID Application}"
# KEYCHAIN_NAME is set by CI workflow - don't set a default here so we can detect if we're in CI
if [ ! -d "$APP_PATH" ]; then
echo "ERROR: App not found at $APP_PATH"
exit 1
fi
# Unlock keychain if password and keychain name are provided (CI environment)
if [ -n "${KEYCHAIN_PASSWORD:-}" ] && [ -n "${KEYCHAIN_NAME:-}" ]; then
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" || true
# Unlock keychain if password provided
if [ -n "${KEYCHAIN_PASSWORD:-}" ]; then
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain || true
fi
# Get the SHA-1 hash of the signing certificate
# Using the hash avoids "ambiguous" errors when the same identity exists in multiple keychains
# We look in the build keychain specifically if KEYCHAIN_NAME is set (CI environment)
# Otherwise fall back to searching all keychains (local dev)
if [ -n "${KEYCHAIN_NAME:-}" ]; then
# CI environment: look for certificate in the build keychain specifically
KEYCHAIN_PATH="$HOME/Library/Keychains/${KEYCHAIN_NAME}-db"
echo "Looking for signing identity in build keychain: $KEYCHAIN_PATH"
echo "Available identities in build keychain:"
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
CERT_HASH=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | grep -E '^\s+[0-9]+\)' | head -1 | awk '{print $2}')
else
# Local dev: search all keychains
echo "Available codesigning identities:"
security find-identity -v -p codesigning
CERT_HASH=$(security find-identity -v -p codesigning | grep -E '^\s+[0-9]+\)' | head -1 | awk '{print $2}')
fi
if [ -z "$CERT_HASH" ]; then
echo "ERROR: No valid signing identity found"
exit 1
fi
echo "Using certificate hash: $CERT_HASH"
# Use the hash as the signing identity to avoid ambiguity
SIGNING_IDENTITY="$CERT_HASH"
echo "=== Signing nested components first ==="
# Sign all dylibs
@@ -73,51 +39,32 @@ find "$APP_PATH" -name "*.bundle" -print0 | while IFS= read -r -d '' item; do
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign XPC services (but skip ones inside Sparkle.framework - they're already signed)
# Sign XPC services (inside Sparkle framework)
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)
# Sign nested apps (like Sparkle's Updater.app)
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)
# Sign standalone executables inside frameworks (like Autoupdate)
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
echo "Signing framework: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
echo "=== Signing main app bundle ==="
+3 -22
View File
@@ -35,7 +35,6 @@ 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'
@@ -265,8 +264,7 @@ main() {
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
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate nginx
# Verify nginx picked up the correct config
local nginx_backend
@@ -299,10 +297,6 @@ main() {
create_flush_marker "${deploy_id}" "eagle-${staging}"
log_info "[DEPLOY:${deploy_id}] Flush marker created - server will auto-invalidate stale cache"
# Write active instance file for eagle-exec helper
echo "eagle-${staging}" > "${ACTIVE_INSTANCE_FILE}"
log_info "[DEPLOY:${deploy_id}] Active instance file updated: eagle-${staging}"
# Update .env for admin service
local env_file="${APP_DIR}/.env"
if [ "$staging" = "green" ]; then
@@ -317,22 +311,9 @@ main() {
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)
# Restart admin to pick up new .env
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"
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate admin
# Clean up old instance
log_info "Cleaning up old eagle-${active}..."
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
curl -L "https://docs.google.com/spreadsheets/d/1DHEsiv4cY4gE6AX3sVH82K__mpBD1aznIYCQwQxA_F0/export?gid=0&format=tsv" | tr -d '\r' > /tmp/names.tsv
bazel run //src/main/scala/net/eagle0/util:name_list_checker -- /tmp/names.tsv > /dev/null
bazel run //src/main/scala/net/eagle0/util:name_list_checker -- /tmp/names.tsv > src/main/resources/net/eagle0/names.tsv
bazel run //src/main/scala/net/eagle0/util:name_list_json_maker -- /tmp/names.tsv > src/main/resources/net/eagle0/names.json
curl -L "https://docs.google.com/spreadsheets/d/1NhvG73HKyVE36yGpkV2oJiSIXoNqQOYTr5ArLnucYL0/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/shardok/battalionTypes.tsv
curl -L "https://docs.google.com/spreadsheets/d/1pNWiyxIks2wJ1v7jRLFD24zrKHG2AfhC-nkWmQKQGN4/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/eagle/heroes.tsv
-46
View File
@@ -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" "$@"
-44
View File
@@ -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
-73
View File
@@ -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
-129
View File
@@ -373,115 +373,6 @@ send_email_fastmail() {
echo "Email sent successfully"
}
# Generate player-friendly "What's New" summary
# This creates a short summary suitable for in-game display
generate_whats_new_summary() {
local pr_file="$1"
local output_file="/tmp/eagle0_whats_new_$$.txt"
echo "Generating player-friendly What's New summary..." >&2
# Create a prompt for player-facing summary
local prompt_file="/tmp/eagle0_whats_new_prompt_$$.txt"
cat > "$prompt_file" <<'WHATS_NEW_PROMPT'
Based on these merged PRs, write a SHORT player-friendly summary.
Focus only on changes players will notice. Ignore internal/technical changes.
Output format (exactly this format, no markdown, no extra text):
TITLE: (5-10 words describing the main change)
DESCRIPTION: (1-2 sentences, what players can now do differently)
CATEGORY: (one of: feature, improvement, fix, content)
If there are multiple notable player-visible changes, pick the single most important one.
If there are no player-visible changes at all, output exactly: NONE
Examples of good output:
TITLE: Bug Reporting
DESCRIPTION: You can now report bugs directly from the Settings menu.
CATEGORY: feature
TITLE: Faster Reconnection
DESCRIPTION: The game now reconnects more smoothly after network interruptions.
CATEGORY: improvement
Here are the merged PRs:
WHATS_NEW_PROMPT
cat "$pr_file" >> "$prompt_file"
# Use Claude CLI to generate the summary
if ! command -v claude &> /dev/null; then
echo "NONE"
rm -f "$prompt_file"
return
fi
cat "$prompt_file" | claude --print > "$output_file" 2>/dev/null
rm -f "$prompt_file"
# Check if the output is NONE
if grep -q "^NONE$" "$output_file"; then
echo "NONE"
rm -f "$output_file"
return
fi
# Return the output
cat "$output_file"
rm -f "$output_file"
}
# URL encode a string for use in query parameters
urlencode() {
local string="$1"
local strlen=${#string}
local encoded=""
local pos c o
for (( pos=0 ; pos<strlen ; pos++ )); do
c=${string:$pos:1}
case "$c" in
[-_.~a-zA-Z0-9] ) o="${c}" ;;
* ) printf -v o '%%%02x' "'$c"
esac
encoded+="${o}"
done
echo "${encoded}"
}
# Open the admin console with pre-populated what's new content
open_whats_new_preview() {
local title="$1"
local description="$2"
local category="$3"
local encoded_title=$(urlencode "$title")
local encoded_desc=$(urlencode "$description")
local encoded_cat=$(urlencode "$category")
local preview_url="https://admin.eagle0.net/whats-new?preview=true&title=${encoded_title}&description=${encoded_desc}&category=${encoded_cat}"
echo ""
echo "=== What's New Preview ==="
echo "Title: $title"
echo "Description: $description"
echo "Category: $category"
echo ""
echo "Opening admin console preview..."
# Open the URL in the default browser
if [[ "$(uname)" == "Darwin" ]]; then
open "$preview_url"
elif command -v xdg-open &> /dev/null; then
xdg-open "$preview_url"
else
echo "Preview URL: $preview_url"
fi
}
# Update the tag to mark this run
update_tag() {
echo "Updating $TAG_NAME tag..."
@@ -571,26 +462,6 @@ main() {
# Update tag for next run
update_tag
# Generate player-friendly What's New summary and open admin preview
echo ""
echo "Generating What's New summary for in-game display..."
whats_new_output=$(generate_whats_new_summary "$pr_file")
if [[ "$whats_new_output" != "NONE" ]]; then
# Parse the output
title=$(echo "$whats_new_output" | grep "^TITLE:" | sed 's/^TITLE:[[:space:]]*//')
description=$(echo "$whats_new_output" | grep "^DESCRIPTION:" | sed 's/^DESCRIPTION:[[:space:]]*//')
category=$(echo "$whats_new_output" | grep "^CATEGORY:" | sed 's/^CATEGORY:[[:space:]]*//')
if [[ -n "$title" && -n "$description" ]]; then
open_whats_new_preview "$title" "$description" "$category"
else
echo "Could not parse What's New output, skipping preview"
fi
else
echo "No player-visible changes detected, skipping What's New preview"
fi
fi
echo ""
-120
View File
@@ -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)"
+6 -18
View File
@@ -50,34 +50,22 @@ if [ "$STATUS" != "Accepted" ]; then
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
# 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 STAPLE_OUTPUT=$(xcrun stapler staple "$APP_PATH" 2>&1); then
echo "$STAPLE_OUTPUT"
if xcrun stapler staple "$APP_PATH"; then
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)"
echo "ERROR: Stapling failed after $MAX_STAPLE_ATTEMPTS attempts"
exit 1
fi
echo "Stapling failed, waiting $STAPLE_WAIT_SECONDS seconds before retry..."
sleep $STAPLE_WAIT_SECONDS
echo "Stapling failed, waiting 10 seconds before retry..."
sleep 10
STAPLE_ATTEMPT=$((STAPLE_ATTEMPT + 1))
done

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