Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 4.6 690d8c8903 Remove 1100+ lines of dead code from TutorialContentDefinitions
RegisterAll() had an early return followed by all the original tutorial
content definitions — unreachable since the narrative dialogue system
replaced them. Remove the dead code, keeping only the empty method stub.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 16:22:23 -07:00
5735 changed files with 888234 additions and 363546 deletions
+18 -52
View File
@@ -1,11 +1,20 @@
# 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
# Try to speed up sandboxes
common --experimental_reuse_sandbox_directories
common --enable_platform_specific_config
common --strategy=Scalac=worker
@@ -14,69 +23,26 @@ common --worker_sandboxing
common --local_test_jobs=64
common --jobs=64
# Keep these C++ standard flags in sync with tools/copts.bzl and scripts/shardok_cpp_config.sh.
common --cxxopt="--std=c++23"
common --cxxopt="-Wno-deprecated-non-prototype"
common --per_file_copt=src/test/cpp/.*@-Wno-character-conversion
common --per_file_copt=src/test/cpp/.*@-Wno-deprecated-enum-compare
common --per_file_copt=src/test/cpp/.*@-Wno-sign-compare
common --per_file_copt=src/test/cpp/.*@-Wno-unknown-warning-option
common --host_cxxopt="--std=c++23"
# C++ sanitizer configs for targeted Shardok safety checks.
# Example: bazel test --config=asan //src/test/cpp/net/eagle0/shardok/library/...
build:asan --compilation_mode=dbg
build:asan --strip=never
build:asan --copt=-fno-omit-frame-pointer
build:asan --copt=-Wno-macro-redefined
build:asan --copt=-fsanitize=address
build:asan --linkopt=-fsanitize=address
test:asan --test_env=ASAN_OPTIONS=detect_leaks=0:strict_init_order=1
build:ubsan --compilation_mode=dbg
build:ubsan --strip=never
build:ubsan --copt=-fno-omit-frame-pointer
build:ubsan --copt=-Wno-macro-redefined
build:ubsan --copt=-fsanitize=undefined
# Protobuf's arena implementation trips UBSan alignment checks in external code on macOS.
build:ubsan --copt=-fno-sanitize=alignment
build:ubsan --linkopt=-fsanitize=undefined
test:ubsan --test_env=UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1
build:tsan --compilation_mode=dbg
build:tsan --strip=never
build:tsan --copt=-fno-omit-frame-pointer
build:tsan --copt=-Wno-macro-redefined
build:tsan --copt=-fsanitize=thread
build:tsan --linkopt=-fsanitize=thread
test:tsan --test_env=TSAN_OPTIONS=halt_on_error=1
common --javacopt="-Xlint:-options"
# Prefer protobuf's prebuilt protoc toolchain instead of building protoc from
# source when compatible binaries are available.
common --@com_google_protobuf//bazel/toolchains:prefer_prebuilt_protoc
# 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
# Pin default DEVELOPER_DIR so Apple repo rules use full Xcode instead of
# Command Line Tools when they are needed.
# Workaround for grpc cf_event_engine build error on macOS with Bazel 7.x
# See: https://github.com/grpc/grpc/issues/37619
common:macos --features=-module_maps
# Pin DEVELOPER_DIR so the apple_cc_autoconf repo rule doesn't re-evaluate
# when Xcode updates in-place. The sync script overrides this for mactools.
common:macos --repo_env=DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
# 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 Apple tool builds. Generated by scripts/sync_bazel_xcode.sh.
# Bakes the Xcode build version into mactools action cache keys without making
# ordinary macOS Bazel builds depend on the installed Xcode build number.
# Xcode config for mactools builds only. Generated by scripts/sync_bazel_xcode.sh.
# Bakes the Xcode build version into action cache keys and sets DEVELOPER_DIR.
try-import %workspace%/.bazelrc.xcode
common --java_language_version=25
+1 -1
View File
@@ -1 +1 @@
9.2.0
8.5.1
-24
View File
@@ -1,24 +0,0 @@
Checks: >
-*,
bugprone-*,
performance-*,
modernize-use-nullptr,
modernize-use-override,
modernize-use-auto,
modernize-use-nodiscard,
readability-braces-around-statements,
readability-qualified-auto,
readability-redundant-member-init,
cppcoreguidelines-pro-type-cstyle-cast,
-bugprone-easily-swappable-parameters,
-bugprone-branch-clone,
-bugprone-signed-char-misuse
WarningsAsErrors: ''
HeaderFilterRegex: 'src/(main|test)/cpp/net/eagle0/(shardok|common)/.*'
FormatStyle: file
CheckOptions:
readability-braces-around-statements.ShortStatementLines: '1'
readability-function-size.LineThreshold: '160'
readability-function-size.StatementThreshold: '80'
readability-function-size.BranchThreshold: '20'
modernize-use-nullptr.NullMacros: 'NULL'
-9
View File
@@ -1,9 +0,0 @@
name: Setup Bazel
description: Ensure Bazel is installed on a self-hosted runner.
runs:
using: composite
steps:
- name: Ensure Bazel installed
shell: bash
run: ./ci/github_actions/ensure_bazel_installed.sh
@@ -0,0 +1,39 @@
name: Artifact Storage Check
on:
schedule:
# Run every 6 hours
- cron: '0 */6 * * *'
workflow_dispatch:
jobs:
check-storage:
runs-on: ubuntu-latest
steps:
- name: Check artifact storage size
env:
GH_TOKEN: ${{ github.token }}
run: |
# Calculate total artifact storage
total_bytes=$(gh api "repos/${{ github.repository }}/actions/artifacts" \
--paginate -q '.artifacts[].size_in_bytes' | awk '{sum+=$1} END {print sum}')
total_mb=$((total_bytes / 1024 / 1024))
echo "Total artifact storage: ${total_mb} MB"
# Fail if over 500MB
if [ "$total_mb" -gt 500 ]; then
echo "::error::Artifact storage is ${total_mb} MB, which exceeds the 500 MB threshold!"
echo ""
echo "Largest artifacts:"
# Save to temp file to avoid SIGPIPE/broken pipe errors with head
gh api "repos/${{ github.repository }}/actions/artifacts" \
--paginate -q '.artifacts[] | "\(.size_in_bytes)\t\(.name)\t\(.created_at)"' > /tmp/artifacts.txt
sort -rn /tmp/artifacts.txt | head -20 | \
awk -F'\t' '{printf "%d MB\t%s\t%s\n", $1/1024/1024, $2, $3}'
rm -f /tmp/artifacts.txt
exit 1
fi
echo "Storage is within acceptable limits."
+4 -61
View File
@@ -6,38 +6,11 @@ on:
paths:
- 'src/main/go/net/eagle0/authservice/**'
- 'src/main/go/net/eagle0/authcli/**'
- 'src/main/go/net/eagle0/common/**'
- 'src/main/go/net/eagle0/util/**'
- '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'
- '.github/actions/setup-bazel/**'
- 'go.mod'
- 'go.sum'
- 'MODULE.bazel'
- 'MODULE.bazel.lock'
- '.bazelrc'
- '.github/workflows/auth_build.yml'
pull_request:
paths:
- 'src/main/go/net/eagle0/authservice/**'
- 'src/main/go/net/eagle0/authcli/**'
- 'src/main/go/net/eagle0/common/**'
- 'src/main/go/net/eagle0/util/**'
- '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'
- '.github/actions/setup-bazel/**'
- 'go.mod'
- 'go.sum'
- 'MODULE.bazel'
- 'MODULE.bazel.lock'
- '.bazelrc'
- '.github/workflows/auth_build.yml'
workflow_dispatch:
@@ -51,45 +24,17 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || 'deploy' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build-auth:
runs-on: [self-hosted, bazel]
outputs:
image_tag: ${{ steps.push-auth.outputs.image_tag }}
steps:
- name: Prepare non-LFS checkout
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig-no-lfs
GIT_CONFIG_NOSYSTEM: "1"
run: |
git config --global --unset-all filter.lfs.process || true
git config --global filter.lfs.smudge "cat"
git config --global filter.lfs.clean "cat"
git config --global filter.lfs.required false
if [ -d ".git" ]; then
git config --local --unset-all filter.lfs.process || true
git config --local filter.lfs.smudge "cat"
git config --local filter.lfs.clean "cat"
git config --local filter.lfs.required false
fi
rm -f .git/hooks/post-checkout .git/hooks/post-merge .git/hooks/pre-push
- name: Checkout repository
uses: actions/checkout@v7
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig-no-lfs
GIT_CONFIG_NOSYSTEM: "1"
uses: actions/checkout@v4
with:
persist-credentials: false
lfs: false
- name: Ensure Bazel installed
uses: ./.github/actions/setup-bazel
- name: Build Auth Server Docker image
id: build-auth
run: |
@@ -182,12 +127,10 @@ jobs:
FASTMAIL_FROM_NAME: ${{ secrets.FASTMAIL_FROM_NAME }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false
uses: actions/checkout@v4
- 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
@@ -195,7 +138,7 @@ jobs:
script_stop: true
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,GH_OAUTH_CLIENT_ID,GH_OAUTH_CLIENT_SECRET,APPLE_SIGNIN_CLIENT_ID,APPLE_TEAM_ID,APPLE_SIGNIN_KEY_ID,APPLE_SIGNIN_PRIVATE_KEY,TWITCH_CLIENT_ID,TWITCH_CLIENT_SECRET,JWT_PRIVATE_KEY,FASTMAIL_API_TOKEN,FASTMAIL_FROM_EMAIL,FASTMAIL_FROM_NAME
script: |
set -e
set -x
cd /opt/eagle0
# Export env vars for docker compose (appleboy/ssh-action sets them but doesn't export)
+38
View File
@@ -0,0 +1,38 @@
name: Bazel Cache Cleanup
on:
schedule:
# Run weekly on Sunday at 00:00 UTC
- cron: '0 0 * * 0'
workflow_dispatch: # Allow manual trigger
jobs:
cleanup:
runs-on: [self-hosted, bazel]
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Show disk usage before cleanup
run: |
echo "=== Disk usage before cleanup ==="
BAZEL_USER_ROOT="/private/var/tmp/_bazel_$(whoami)"
echo "Bazel user root: $BAZEL_USER_ROOT"
du -sh "$BAZEL_USER_ROOT" 2>/dev/null || echo "Not found"
df -h .
- name: Run bazel clean
run: |
echo "=== Running bazel clean ==="
bazel clean
echo "Clean complete"
- name: Show disk usage after cleanup
run: |
echo "=== Disk usage after cleanup ==="
BAZEL_USER_ROOT="/private/var/tmp/_bazel_$(whoami)"
du -sh "$BAZEL_USER_ROOT" 2>/dev/null || echo "Not found"
df -h .
@@ -1,136 +0,0 @@
name: Bazel Cache Maintenance
on:
workflow_dispatch:
inputs:
target:
description: 'Maintenance target'
required: true
default: 'parity'
type: choice
options:
- parity
- clean
schedule:
- cron: '17 11 * * *'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
jobs:
warm-cache:
if: github.event_name == 'schedule' || github.event.inputs.target == 'parity'
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@v7
with:
persist-credentials: false
lfs: false
- name: Ensure Bazel installed
uses: ./.github/actions/setup-bazel
- name: Warm remote cache
run: ./scripts/check_bazel_remote_cache_parity.sh warm
verify-cache:
needs: warm-cache
if: github.event_name == 'schedule' || github.event.inputs.target == 'parity'
runs-on: [self-hosted, bazel]
strategy:
fail-fast: false
matrix:
probe: [a, b]
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@v7
with:
persist-credentials: false
lfs: false
- name: Ensure Bazel installed
uses: ./.github/actions/setup-bazel
- name: Verify remote cache parity
run: ./scripts/check_bazel_remote_cache_parity.sh verify
clean-cache:
if: github.event_name == 'workflow_dispatch' && github.event.inputs.target == 'clean'
runs-on: [self-hosted, bazel]
steps:
- name: Checkout
uses: actions/checkout@v7
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 .
+5 -66
View File
@@ -7,16 +7,8 @@ on:
- 'src/**'
- 'WORKSPACE'
- 'MODULE.bazel'
- 'MODULE.bazel.lock'
- 'BUILD.bazel'
- '.bazelrc'
- 'ci/github_actions/ensure_bazel_installed.sh'
- 'ci/github_actions/summarize_bazel_bep.py'
- '.clang-tidy'
- 'scripts/run-clang-tidy.sh'
- 'scripts/check_shardok_clang_tidy.sh'
- 'scripts/sync_bazel_xcode.sh'
- '.github/actions/setup-bazel/**'
- '.github/workflows/bazel_test.yml'
- '!src/main/csharp/**'
- '!src/test/csharp/**'
@@ -25,16 +17,8 @@ on:
- 'src/**'
- 'WORKSPACE'
- 'MODULE.bazel'
- 'MODULE.bazel.lock'
- 'BUILD.bazel'
- '.bazelrc'
- 'ci/github_actions/ensure_bazel_installed.sh'
- 'ci/github_actions/summarize_bazel_bep.py'
- '.clang-tidy'
- 'scripts/run-clang-tidy.sh'
- 'scripts/check_shardok_clang_tidy.sh'
- 'scripts/sync_bazel_xcode.sh'
- '.github/actions/setup-bazel/**'
- '.github/workflows/bazel_test.yml'
- '!src/main/csharp/**'
- '!src/test/csharp/**'
@@ -56,38 +40,18 @@ jobs:
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@v7
uses: actions/checkout@v4
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@v7
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Check JavaScript syntax
run: node --check src/main/go/net/eagle0/admin_server/static/map_editor.js
- name: Check Shardok clang-tidy gate
run: ./scripts/check_shardok_clang_tidy.sh
test:
runs-on: [self-hosted, bazel]
@@ -98,37 +62,12 @@ jobs:
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@v7
uses: actions/checkout@v4
with:
persist-credentials: false
lfs: false
- name: Setup Bazel
uses: ./.github/actions/setup-bazel
- name: Sync Bazel Xcode config
run: ./scripts/sync_bazel_xcode.sh
- name: Run tests
run: bazel test --build_event_json_file=test.json //src/test/... //src/main/go/...
- name: Summarize Bazel build metrics
if: always()
run: python3 ci/github_actions/summarize_bazel_bep.py test.json
- name: Collect failed test logs
if: always()
run: |
@@ -163,14 +102,14 @@ 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/
+27
View File
@@ -0,0 +1,27 @@
name: Blob Cleanup
on:
schedule:
# Run daily at 04:00 UTC
- cron: '0 4 * * *'
workflow_dispatch: # Allow manual trigger
permissions:
contents: read
jobs:
cleanup:
runs-on: [self-hosted, bazel]
steps:
- uses: actions/checkout@v4
with:
lfs: false
clean: false
- name: Clean up unreferenced blobs
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: |
bazel run //src/main/go/net/eagle0/build/blob_cleanup:blob_cleanup -- --min-age=1h
+78 -32
View File
@@ -22,36 +22,20 @@ permissions:
contents: read
jobs:
build-sysroot:
name: Build Linux Sysroot (${{ matrix.architecture }})
build-sysroot-amd64:
if: ${{ inputs.architecture == 'amd64' || inputs.architecture == 'both' }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix: ${{ fromJSON(inputs.architecture == 'both' && '{"include":[{"architecture":"amd64","build_script":"./tools/sysroot/build_sysroot.sh","artifact_name":"ubuntu-noble-sysroot-amd64","archive_name":"ubuntu_noble_amd64_sysroot.tar.xz","checksum_name":"ubuntu_noble_amd64_sysroot.sha256","module_name":"linux_sysroot"},{"architecture":"arm64","build_script":"./tools/sysroot/build_sysroot_arm64.sh","artifact_name":"ubuntu-noble-sysroot-arm64","archive_name":"ubuntu_noble_arm64_sysroot.tar.xz","checksum_name":"ubuntu_noble_arm64_sysroot.sha256","module_name":"linux_sysroot_arm64"}]}' || inputs.architecture == 'arm64' && '{"include":[{"architecture":"arm64","build_script":"./tools/sysroot/build_sysroot_arm64.sh","artifact_name":"ubuntu-noble-sysroot-arm64","archive_name":"ubuntu_noble_arm64_sysroot.tar.xz","checksum_name":"ubuntu_noble_arm64_sysroot.sha256","module_name":"linux_sysroot_arm64"}]}' || '{"include":[{"architecture":"amd64","build_script":"./tools/sysroot/build_sysroot.sh","artifact_name":"ubuntu-noble-sysroot-amd64","archive_name":"ubuntu_noble_amd64_sysroot.tar.xz","checksum_name":"ubuntu_noble_amd64_sysroot.sha256","module_name":"linux_sysroot"}]}') }}
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up QEMU for ARM64 emulation
if: ${{ matrix.architecture == 'arm64' }}
uses: docker/setup-qemu-action@v4
with:
platforms: arm64
- name: Set up Docker Buildx
if: ${{ matrix.architecture == 'arm64' }}
uses: docker/setup-buildx-action@v4
uses: actions/checkout@v4
- name: Build sysroot
run: ${{ matrix.build_script }}
run: ./tools/sysroot/build_sysroot.sh
- name: Upload sysroot artifact
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
name: ubuntu-noble-sysroot-amd64
path: tools/sysroot/output/
retention-days: 1
@@ -69,25 +53,87 @@ jobs:
AWS_SECRET_ACCESS_KEY: ${{ secrets.SECRET_KEY }}
run: |
# Upload sysroot tarball to DO Spaces
aws s3 cp "tools/sysroot/output/${{ matrix.archive_name }}" \
"s3://eagle0-sysroot/${{ inputs.version }}/${{ matrix.archive_name }}" \
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.tar.xz \
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
# Upload sha256 file
aws s3 cp "tools/sysroot/output/${{ matrix.checksum_name }}" \
"s3://eagle0-sysroot/${{ inputs.version }}/${{ matrix.checksum_name }}" \
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256 \
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.sha256 \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
echo ""
echo "=== ${{ matrix.architecture }} Sysroot uploaded ==="
echo "URL: https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/${{ matrix.archive_name }}"
echo "SHA256: $(cat "tools/sysroot/output/${{ matrix.checksum_name }}")"
echo "=== AMD64 Sysroot uploaded ==="
echo "URL: https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz"
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)"
echo ""
echo "Update MODULE.bazel with:"
echo "sysroot("
echo " name = \"${{ matrix.module_name }}\","
echo " sha256 = \"$(cat "tools/sysroot/output/${{ matrix.checksum_name }}")\","
echo " urls = [\"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/${{ matrix.archive_name }}\"],"
echo " name = \"linux_sysroot\","
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)\","
echo " urls = [\"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
echo ")"
build-sysroot-arm64:
if: ${{ inputs.architecture == 'arm64' || inputs.architecture == 'both' }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU for ARM64 emulation
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build ARM64 sysroot
run: ./tools/sysroot/build_sysroot_arm64.sh
- name: Upload sysroot artifact
uses: actions/upload-artifact@v4
with:
name: ubuntu-noble-sysroot-arm64
path: tools/sysroot/output/
retention-days: 1
- name: Install AWS CLI
run: |
if ! command -v aws &> /dev/null; then
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip -q awscliv2.zip
sudo ./aws/install
fi
- name: Upload to DigitalOcean Spaces
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SECRET_KEY }}
run: |
# Upload sysroot tarball to DO Spaces
aws s3 cp tools/sysroot/output/ubuntu_noble_arm64_sysroot.tar.xz \
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
# Upload sha256 file
aws s3 cp tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256 \
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.sha256 \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
echo ""
echo "=== ARM64 Sysroot uploaded ==="
echo "URL: https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz"
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256)"
echo ""
echo "Update MODULE.bazel with:"
echo "sysroot("
echo " name = \"linux_sysroot_arm64\","
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256)\","
echo " urls = [\"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz\"],"
echo ")"
+13 -106
View File
@@ -18,36 +18,7 @@ on:
- '!src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto'
- 'src/main/resources/**'
- 'ci/BUILD.bazel'
- 'ci/github_actions/ensure_bazel_installed.sh'
- 'scripts/sync_bazel_xcode.sh'
- '.github/actions/setup-bazel/**'
- 'MODULE.bazel'
- 'MODULE.bazel.lock'
- '.bazelrc'
- 'docker-compose.prod.yml'
- 'nginx/**'
- '.github/workflows/docker_build.yml'
pull_request:
paths:
# Note: Auth changes trigger auth_build.yml instead
# Note: Windows installer changes trigger installer_build.yml instead
- 'src/main/go/net/eagle0/admin_server/**'
- 'src/main/go/net/eagle0/common/**'
- 'src/main/go/net/eagle0/util/**'
- 'src/main/scala/**'
- 'src/main/protobuf/**'
- '!src/main/protobuf/net/eagle0/eagle/api/auth.proto'
- '!src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto'
- 'src/main/resources/**'
- 'ci/BUILD.bazel'
- 'ci/github_actions/ensure_bazel_installed.sh'
- 'ci/github_actions/fetch_lfs.sh'
- 'scripts/sync_bazel_xcode.sh'
- '.github/actions/setup-bazel/**'
- 'go.mod'
- 'go.sum'
- 'MODULE.bazel'
- 'MODULE.bazel.lock'
- '.bazelrc'
- 'docker-compose.prod.yml'
- 'nginx/**'
@@ -64,8 +35,8 @@ on:
# The build-all job checks if it's still the latest commit on main and skips if not,
# so intermediate commits don't waste time building when a newer one is already queued.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || 'deploy' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
group: docker-build-deploy
cancel-in-progress: false
permissions:
contents: read
@@ -81,10 +52,8 @@ jobs:
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 }}
skip_build: ${{ steps.check-latest.outputs.skip }}
steps:
- name: Skip if superseded
if: github.event_name != 'pull_request'
id: check-latest
run: |
# Check if there's a newer run of this workflow waiting in the queue.
@@ -99,42 +68,14 @@ jobs:
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@v7
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'
uses: ./.github/actions/setup-bazel
- name: Sync Bazel Xcode config
if: steps.check-latest.outputs.skip != 'true'
run: ./scripts/sync_bazel_xcode.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
@@ -150,7 +91,7 @@ jobs:
bazel build \
--stamp \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:cc-toolchain-x86_64-linux \
--extra_toolchains=@llvm_toolchain_linux//:all \
//ci:eagle_server_image \
//ci:admin_server_image \
//ci:jfr_sidecar_image \
@@ -175,8 +116,8 @@ jobs:
echo "JFR Sidecar: $JFR_PATH"
- name: Upload warmup binary
if: steps.check-latest.outputs.skip != 'true' && github.event_name != 'pull_request'
uses: actions/upload-artifact@v7
if: steps.check-latest.outputs.skip != 'true'
uses: actions/upload-artifact@v4
with:
name: warmup-binary
path: scripts/bin/warmup
@@ -195,7 +136,7 @@ jobs:
- name: Push all images to DO registry
id: push-images
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:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
@@ -261,9 +202,9 @@ jobs:
echo "=== All images pushed successfully ==="
deploy:
runs-on: ubuntu-latest
runs-on: [self-hosted, bazel]
needs: [build-all]
if: needs.build-all.outputs.skip_build != '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')
environment: production
env:
EAGLE_IMAGE: ${{ needs.build-all.outputs.eagle_image_tag }}
@@ -274,7 +215,6 @@ jobs:
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GPT_MODEL_NAME: ${{ secrets.GPT_MODEL_NAME }}
EAGLE_ENABLE_S3: ${{ secrets.EAGLE_ENABLE_S3 }}
DO_SPACES_ENDPOINT: ${{ secrets.DO_SPACES_ENDPOINT }}
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)
@@ -299,37 +239,14 @@ jobs:
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@v7
env:
GIT_LFS_SKIP_SMUDGE: 1
uses: actions/checkout@v4
with:
persist-credentials: false
lfs: false
- name: Setup SSH key
@@ -340,7 +257,7 @@ jobs:
ssh-keyscan -H "$DO_DROPLET_IP" >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Download warmup binary
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: warmup-binary
path: scripts/bin/
@@ -363,7 +280,7 @@ jobs:
- 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
# =================================================================
@@ -401,10 +318,6 @@ jobs:
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 ""
@@ -452,12 +365,6 @@ jobs:
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}"
@@ -567,7 +474,7 @@ jobs:
cleanup:
needs: [build-all, deploy]
if: always() && github.event_name != 'pull_request'
if: always()
runs-on: ubuntu-latest
steps:
- name: Delete warmup-binary artifact
-86
View File
@@ -1,86 +0,0 @@
name: Eagle0pedia Build
on:
push:
branches: [ "main" ]
paths:
- 'docs/eagle0pedia/**'
- 'ci/github_actions/ensure_bazel_installed.sh'
- '.github/actions/setup-bazel/**'
- '.github/workflows/eagle0pedia_build.yml'
- 'MODULE.bazel'
- 'MODULE.bazel.lock'
- '.bazelrc'
pull_request:
paths:
- 'docs/eagle0pedia/**'
- 'ci/github_actions/ensure_bazel_installed.sh'
- '.github/actions/setup-bazel/**'
- '.github/workflows/eagle0pedia_build.yml'
- 'MODULE.bazel'
- 'MODULE.bazel.lock'
- '.bazelrc'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
build:
runs-on: [self-hosted, bazel]
steps:
- name: Prepare workspace
run: |
chmod -R u+w eagle0pedia-dist 2>/dev/null || true
rm -rf eagle0pedia-dist
- name: Prepare non-LFS checkout
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig-no-lfs
GIT_CONFIG_NOSYSTEM: "1"
run: |
git config --global --unset-all filter.lfs.process || true
git config --global filter.lfs.smudge "cat"
git config --global filter.lfs.clean "cat"
git config --global filter.lfs.required false
if [ -d ".git" ]; then
git config --local --unset-all filter.lfs.process || true
git config --local filter.lfs.smudge "cat"
git config --local filter.lfs.clean "cat"
git config --local filter.lfs.required false
fi
rm -f .git/hooks/post-checkout .git/hooks/post-merge .git/hooks/pre-push
- name: Checkout repository
uses: actions/checkout@v7
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig-no-lfs
GIT_CONFIG_NOSYSTEM: "1"
with:
persist-credentials: false
lfs: false
- name: Setup Bazel
uses: ./.github/actions/setup-bazel
- name: Build Eagle0pedia
run: bazel build //docs/eagle0pedia:site
- name: Stage static site artifact
run: |
rm -rf eagle0pedia-dist
mkdir -p eagle0pedia-dist
cp -R bazel-bin/docs/eagle0pedia/dist/. eagle0pedia-dist/
chmod -R u+w eagle0pedia-dist
- name: Upload static site artifact
uses: actions/upload-artifact@v7
with:
name: eagle0pedia-dist
path: eagle0pedia-dist/
if-no-files-found: error
retention-days: 3
+33
View File
@@ -0,0 +1,33 @@
name: Eagle Build
on:
# Main pushes are covered by docker_build.yml which builds the same target
pull_request:
paths:
- 'src/main/scala/**'
- 'src/main/protobuf/net/eagle0/eagle/**'
- 'src/main/protobuf/net/eagle0/common/**'
- 'WORKSPACE'
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.bazelrc'
- '.github/workflows/eagle_build.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
build:
runs-on: [self-hosted, bazel]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Eagle server
run: bazel build //src/main/scala/net/eagle0/eagle:eagle_server
+2 -29
View File
@@ -5,16 +5,10 @@ on:
branches: [ "main" ]
paths:
- ".github/workflows/installer_build.yml"
- ".github/actions/setup-bazel/**"
- "ci/github_actions/ensure_bazel_installed.sh"
- ".bazelrc"
- "src/main/go/net/eagle0/clients/win/installer/**"
pull_request:
paths:
- ".github/workflows/installer_build.yml"
- ".github/actions/setup-bazel/**"
- "ci/github_actions/ensure_bazel_installed.sh"
- ".bazelrc"
- "src/main/go/net/eagle0/clients/win/installer/**"
workflow_dispatch:
@@ -27,32 +21,11 @@ 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
- uses: actions/checkout@v7
- uses: actions/checkout@v4
with:
persist-credentials: false
lfs: false
clean: false
- name: Setup Bazel
uses: ./.github/actions/setup-bazel
- name: Build Go installer for Windows
env:
MANIFEST_PUBLIC_KEY: ${{ secrets.MANIFEST_PUBLIC_KEY }}
@@ -77,7 +50,7 @@ jobs:
- name: Archive installer binary
if: success() || failure()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: eagle-installer
path: ./installer-output/
+8 -113
View File
@@ -13,7 +13,7 @@ on:
permissions:
contents: read
actions: write
actions: read
env:
# Runner-specific build directory to allow parallel builds on multiple runners
@@ -31,7 +31,7 @@ jobs:
steps:
- name: Check for relevant changes since last successful run
id: check
uses: actions/github-script@v9
uses: actions/github-script@v8
with:
script: |
if (context.eventName === 'workflow_dispatch') {
@@ -74,7 +74,6 @@ jobs:
'ci/', // CI scripts & workflows
'.github/workflows/ios_testflight.yml',
'MODULE.bazel',
'MODULE.bazel.lock',
'.bazelrc',
];
@@ -123,26 +122,8 @@ jobs:
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@v7
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
fetch-depth: 0
@@ -153,133 +134,47 @@ jobs:
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: Ensure Bazel installed
uses: ./.github/actions/setup-bazel
- name: Sync Bazel Xcode config
run: ./scripts/sync_bazel_xcode.sh
- 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() && github.event.inputs.skip_upload != 'true'
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() && github.event.inputs.skip_upload != 'true'
if: success()
env:
DO_CDN_PAT: ${{ secrets.DO_CDN_PAT }}
run: |
UNITY_VERSION=$(grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //')
UNITY_MAJOR_MINOR=$(echo "$UNITY_VERSION" | sed -E 's/^([0-9]+)\.([0-9]+).*/\1.\2/')
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/$UNITY_MAJOR_MINOR/*\"]}" \
-d '{"files": ["addressables/iOS/*"]}' \
--fail || echo "Warning: CDN purge failed (non-fatal)"
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
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]
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@v7
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
uses: actions/github-script@v9
with:
script: |
const artifactName = 'eagle0-ios-project-${{ github.run_id }}';
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.runId,
});
const artifact = artifacts.find(a => a.name === artifactName);
if (artifact === undefined) {
console.log(`Artifact not found: ${artifactName}`);
return;
}
await github.rest.actions.deleteArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: artifact.id,
});
console.log(`Deleted artifact: ${artifactName} (${artifact.id})`);
- name: Install Signing Certificate
env:
IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }}
@@ -369,7 +264,7 @@ jobs:
- 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
uses: actions/upload-artifact@v4
with:
name: eagle0-ios-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/archive/eagle0.ipa
+121 -262
View File
@@ -14,22 +14,16 @@ on:
- "src/main/go/net/eagle0/build/mac_build_handler/**"
- "scripts/build_protos.sh"
- "scripts/build_mac_plugin.sh"
- "scripts/build_sparkle_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"
- ".github/actions/setup-bazel/**"
- "ci/github_actions/detect_addressables_changes.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"
- ".bazelrc"
- "ci/mac/**"
- "src/main/objc/net/eagle0/clients/unity/sparkle/**"
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.
@@ -38,11 +32,6 @@ on:
- "src/main/go/net/eagle0/build/mac_build_handler/**"
- "scripts/build_mac_plugin.sh"
- "scripts/build_sparkle_plugin.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Editor/BuildScript.cs"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/SparkleInitializer.cs"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/SparkleUpdater.cs"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/common/UpdateNotification/UpdateNotificationManager.cs"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/common/WindowFocusManager.cs"
- "src/main/objc/net/eagle0/clients/unity/sparkle/**"
- "scripts/inject_sparkle.sh"
- "scripts/codesign_mac_app.sh"
@@ -50,13 +39,6 @@ on:
- "scripts/notarize_wait.sh"
- "ci/github_actions/build_mac.sh"
- "ci/github_actions/build_unity_mac.sh"
- "ci/github_actions/ensure_unity_installed.sh"
- "ci/github_actions/ensure_bazel_installed.sh"
- ".github/actions/setup-bazel/**"
- "ci/github_actions/detect_addressables_changes.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectSettings.asset"
- ".bazelrc"
- "ci/mac/**"
workflow_dispatch:
inputs:
@@ -66,6 +48,10 @@ 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
@@ -77,14 +63,11 @@ env:
KEYCHAIN_NAME: build-${{ github.run_id }}.keychain
jobs:
build-mac:
build-and-sign:
runs-on: [self-hosted, macOS, unity-mac]
concurrency:
group: ${{ github.workflow }}-build-${{ github.head_ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
outputs:
submission_id: ${{ steps.notarize-submit.outputs.submission_id }}
should_deploy: ${{ steps.check-deploy.outputs.should_deploy }}
addressables_changed: ${{ steps.addressables.outputs.changed }}
steps:
- name: Prune stale PR refs
@@ -98,26 +81,8 @@ jobs:
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@v7
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
fetch-depth: 0 # For version numbering from git history
@@ -125,25 +90,11 @@ jobs:
- 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"
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
@@ -158,53 +109,35 @@ jobs:
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 mac
- name: Ensure .NET SDK installed
uses: actions/setup-dotnet@v5
with:
dotnet-version: '8.0.x'
- name: Ensure Bazel installed
uses: ./.github/actions/setup-bazel
- name: Sync Bazel Xcode config
run: ./scripts/sync_bazel_xcode.sh
- name: Detect Addressables changes
id: addressables
if: success()
shell: bash
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
BASE_SHA="${{ github.event.pull_request.base.sha }}"
else
BASE_SHA="${{ github.event.before }}"
fi
./ci/github_actions/detect_addressables_changes.sh "$BASE_SHA" "${{ github.sha }}"
- name: Build Mac Unity
run: ./ci/github_actions/build_unity_mac.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC" "${{ steps.addressables.outputs.changed }}" "${{ steps.addressables.outputs.changed }}"
run: ./ci/github_actions/build_unity_mac.sh "${{ env.EAGLE0_BUILD_DIR }}/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
run: git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
- name: Save Unity version for Library/ cache invalidation
- 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: |
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
chmod +x ./scripts/inject_sparkle.sh
./scripts/inject_sparkle.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app"
- name: Check if should deploy
id: check-deploy
@@ -219,146 +152,8 @@ jobs:
echo "should_deploy=false" >> $GITHUB_OUTPUT
fi
- name: Zip unsigned app for deploy
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
run: |
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
ditto -c -k --keepParent eagle0.app eagle0.app.zip
- name: Upload unsigned app
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
uses: actions/upload-artifact@v7
with:
name: unsigned-mac-app-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app.zip
retention-days: 1
- name: Upload Mac Addressables for deploy
if: success() && steps.check-deploy.outputs.should_deploy == 'true' && steps.addressables.outputs.changed == 'true'
uses: actions/upload-artifact@v7
with:
name: mac-addressables-${{ github.run_id }}
path: src/main/csharp/net/eagle0/clients/unity/eagle0/ServerData/StandaloneOSX
if-no-files-found: ignore
retention-days: 1
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v7
with:
name: editor_mac.log
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_mac.log
retention-days: 3
- name: Archive Addressables build reports
if: (success() || failure()) && steps.addressables.outputs.changed == 'true'
uses: actions/upload-artifact@v7
with:
name: mac-addressables-build-reports
path: src/main/csharp/net/eagle0/clients/unity/eagle0/Library/com.unity.addressables/BuildReports
if-no-files-found: ignore
retention-days: 3
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
deploy-mac:
needs: build-mac
if: needs.build-mac.outputs.should_deploy == 'true'
runs-on: [self-hosted, macOS, notarize]
outputs:
deployed_version: ${{ steps.deploy-mac.outputs.deployed_version }}
deployed_unity_major_minor: ${{ steps.deploy-mac.outputs.deployed_unity_major_minor }}
concurrency:
group: mac-deploy-${{ github.ref }}
cancel-in-progress: false
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@v7
env:
GIT_LFS_SKIP_SMUDGE: 1
with:
persist-credentials: false
fetch-depth: 0 # For version numbering
- name: Check if run is still latest main
id: check-latest
run: |
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
LATEST_SHA=$(curl -fsSL \
-H "Authorization: Bearer ${{ github.token }}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${{ github.repository }}/commits/main" \
| python3 -c 'import json, sys; print(json.load(sys.stdin)["sha"])')
if [[ "$LATEST_SHA" != "${{ github.sha }}" ]]; then
echo "Skipping deploy because ${{ github.sha }} is no longer latest main ($LATEST_SHA is latest)"
echo "should_deploy=false" >> "$GITHUB_OUTPUT"
exit 0
fi
fi
echo "should_deploy=true" >> "$GITHUB_OUTPUT"
- name: Clean download directory
if: steps.check-latest.outputs.should_deploy == 'true'
run: rm -rf ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
- name: Download unsigned app
if: steps.check-latest.outputs.should_deploy == 'true'
uses: actions/download-artifact@v8
with:
name: unsigned-mac-app-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
- name: Unzip unsigned app
if: steps.check-latest.outputs.should_deploy == 'true'
run: |
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
ditto -x -k eagle0.app.zip .
rm eagle0.app.zip
ls -la ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app/
- name: Clean Addressables directory
if: steps.check-latest.outputs.should_deploy == 'true' && needs.build-mac.outputs.addressables_changed == 'true'
run: rm -rf src/main/csharp/net/eagle0/clients/unity/eagle0/ServerData/StandaloneOSX
- name: Download Mac Addressables
if: steps.check-latest.outputs.should_deploy == 'true' && needs.build-mac.outputs.addressables_changed == 'true'
uses: actions/download-artifact@v8
continue-on-error: true
with:
name: mac-addressables-${{ github.run_id }}
path: src/main/csharp/net/eagle0/clients/unity/eagle0/ServerData/StandaloneOSX
- name: Upload Addressables to CDN
if: steps.check-latest.outputs.should_deploy == 'true' && needs.build-mac.outputs.addressables_changed == 'true'
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: steps.check-latest.outputs.should_deploy == 'true'
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: Import Code Signing Certificate
if: steps.check-latest.outputs.should_deploy == 'true'
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
@@ -401,7 +196,7 @@ jobs:
rm certificate.p12
- name: Code Sign App
if: steps.check-latest.outputs.should_deploy == 'true'
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
env:
SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
run: |
@@ -410,7 +205,7 @@ jobs:
- name: Submit for Notarization
id: notarize-submit
if: steps.check-latest.outputs.should_deploy == 'true'
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
@@ -419,47 +214,119 @@ jobs:
chmod +x ./scripts/notarize_submit.sh
./scripts/notarize_submit.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app" >> $GITHUB_OUTPUT
- name: Cleanup Keychain
if: always()
run: |
security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true
- name: Zip signed app for artifact
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
run: |
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
ditto -c -k --keepParent eagle0.app eagle0.app.zip
- name: Upload signed app
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
uses: actions/upload-artifact@v4
with:
name: signed-mac-app-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app.zip
retention-days: 1
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: editor_mac.log
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_mac.log
retention-days: 3
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
wait-notarization:
needs: build-and-sign
if: needs.build-and-sign.outputs.should_deploy == 'true'
runs-on: [self-hosted, macOS, notarize]
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts
- name: Clean download directory
run: rm -rf ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
- name: Download signed app
uses: actions/download-artifact@v4
with:
name: signed-mac-app-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
- name: Unzip signed app
run: |
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
ditto -x -k eagle0.app.zip .
rm eagle0.app.zip
ls -la ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app/
- name: Wait for Notarization and Staple
if: steps.check-latest.outputs.should_deploy == 'true'
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
chmod +x ./scripts/notarize_wait.sh
./scripts/notarize_wait.sh "${{ steps.notarize-submit.outputs.submission_id }}" "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app"
- name: Cleanup Keychain
if: always()
run: |
security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true
./scripts/notarize_wait.sh "${{ needs.build-and-sign.outputs.submission_id }}" "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app"
- name: Zip notarized app for artifact
if: steps.check-latest.outputs.should_deploy == 'true'
run: |
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
rm -f eagle0.app.zip
ditto -c -k --keepParent eagle0.app eagle0.app.zip
- name: Upload notarized app
if: steps.check-latest.outputs.should_deploy == 'true'
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
retention-days: 1
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
- name: Ensure Bazel installed
if: steps.check-latest.outputs.should_deploy == 'true'
uses: ./.github/actions/setup-bazel
deploy:
needs: [build-and-sign, wait-notarization]
if: needs.build-and-sign.outputs.should_deploy == 'true'
runs-on: [self-hosted, macOS, unity-mac]
outputs:
deployed_version: ${{ steps.deploy-mac.outputs.deployed_version }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # For version numbering
- name: Clean download directory
run: rm -rf ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
- name: Download notarized app
uses: actions/download-artifact@v4
with:
name: notarized-mac-app-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
- name: Unzip notarized app
run: |
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
ditto -x -k eagle0.app.zip .
rm eagle0.app.zip
- name: Sync Bazel Xcode config
if: steps.check-latest.outputs.should_deploy == 'true'
run: ./scripts/sync_bazel_xcode.sh
- name: Deploy Mac Build
id: deploy-mac
if: steps.check-latest.outputs.should_deploy == 'true'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
@@ -470,13 +337,8 @@ jobs:
echo "$SPARKLE_EDDSA_PRIVATE_KEY" > "$SPARKLE_PRIVATE_KEY_PATH"
chmod 600 "$SPARKLE_PRIVATE_KEY_PATH"
# Install dmgbuild in an isolated venv. Homebrew Python rejects system
# installs under PEP 668, and mac_build_handler shells out to dmgbuild.
DMGBUILD_VENV="${{ env.EAGLE0_BUILD_DIR }}/dmgbuild-venv"
python3 -m venv "$DMGBUILD_VENV"
"$DMGBUILD_VENV/bin/python" -m pip install --upgrade pip
"$DMGBUILD_VENV/bin/python" -m pip install dmgbuild
export PATH="$DMGBUILD_VENV/bin:$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"
@@ -496,9 +358,6 @@ jobs:
rm "$SPARKLE_PRIVATE_KEY_PATH"
echo "deployed_version=$VERSION" >> $GITHUB_OUTPUT
UNITY_VERSION=$(grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //')
UNITY_MAJOR_MINOR=$(echo "$UNITY_VERSION" | sed -E 's/^([0-9]+)\.([0-9]+).*/\1.\2/')
echo "deployed_unity_major_minor=$UNITY_MAJOR_MINOR" >> $GITHUB_OUTPUT
# Artifact cleanup is handled by the cleanup job on ubuntu-latest
- name: Cleanup build directory
@@ -506,8 +365,8 @@ jobs:
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
notify-mac:
needs: deploy-mac
if: needs.deploy-mac.outputs.deployed_version != ''
needs: deploy
if: needs.deploy.outputs.deployed_version != ''
runs-on: ubuntu-latest
steps:
- name: Purge CDN cache and notify clients
@@ -519,16 +378,16 @@ jobs:
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/${{ needs.deploy-mac.outputs.deployed_unity_major_minor }}/*"]}' \
-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-mac.outputs.deployed_version }}&required=false" \
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-mac, deploy-mac, notify-mac]
needs: [build-and-sign, wait-notarization, deploy, notify-mac]
if: always()
runs-on: ubuntu-latest
@@ -538,7 +397,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
# Delete this run's artifacts (names include run ID to avoid conflicts)
for artifact_name in unsigned-mac-app-${{ github.run_id }} notarized-mac-app-${{ github.run_id }} mac-addressables-${{ github.run_id }}; do
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")
+92
View File
@@ -0,0 +1,92 @@
name: Cleanup Old Container Images
on:
schedule:
# Run daily at 3am UTC
- cron: '0 3 * * *'
workflow_dispatch:
inputs:
dry_run:
description: 'Dry run (show what would be deleted without deleting)'
required: true
default: 'true'
type: boolean
permissions:
contents: read
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Install doctl
uses: digitalocean/action-doctl@v2
with:
token: ${{ secrets.DO_REGISTRY_TOKEN }}
- name: Cleanup old images
env:
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true' }}
run: |
set -e
RETENTION_DAYS=5
CUTOFF_DATE=$(date -d "-${RETENTION_DAYS} days" +%s)
REGISTRY="eagle0"
echo "Cleaning up images older than ${RETENTION_DAYS} days"
echo "Cutoff date: $(date -d "@${CUTOFF_DATE}" -Iseconds)"
echo "Dry run: ${DRY_RUN}"
echo ""
# List of repositories to clean
# Use tail to skip header row in case --no-header doesn't work
REPOS=$(doctl registry repository list-v2 --format Name --no-header | grep -v '^Name$' | grep -v '^$')
for REPO in $REPOS; do
echo "=== Processing repository: ${REPO} ==="
# Get all manifests with their tags and dates using JSON output for reliable parsing
MANIFESTS_JSON=$(doctl registry repository list-manifests "${REPO}" --output json 2>/dev/null || echo "[]")
if [ "$MANIFESTS_JSON" = "[]" ] || [ -z "$MANIFESTS_JSON" ]; then
echo " No manifests found"
continue
fi
# Parse JSON and process each manifest
echo "$MANIFESTS_JSON" | jq -r '.[] | "\(.digest) \(.updated_at) \(.tags // [] | join(","))"' | while read -r DIGEST UPDATED_AT TAGS; do
# Skip if no digest or if it doesn't look like a valid digest (sha256:...)
if [ -z "$DIGEST" ] || ! echo "$DIGEST" | grep -q '^sha256:'; then
continue
fi
# Parse the date (ISO 8601 format from JSON)
MANIFEST_DATE=$(date -d "$UPDATED_AT" +%s 2>/dev/null || echo "0")
# Skip protected tags (latest, arm64-latest)
if echo ",$TAGS," | grep -qE ',(latest|arm64-latest),'; then
echo " KEEP: ${DIGEST:0:20}... (protected tag: $TAGS)"
continue
fi
# Check if older than cutoff
if [ "$MANIFEST_DATE" -lt "$CUTOFF_DATE" ]; then
echo " DELETE: ${DIGEST:0:20}... (updated: $UPDATED_AT, tags: $TAGS)"
if [ "$DRY_RUN" != "true" ]; then
doctl registry repository delete-manifest "${REPO}" "$DIGEST" --force
fi
else
echo " KEEP: ${DIGEST:0:20}... (updated: $UPDATED_AT, tags: $TAGS)"
fi
done
echo ""
done
- name: Run garbage collection
if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'false')
run: |
echo "Starting garbage collection..."
doctl registry garbage-collection start --force
echo "Garbage collection started. It may take a few minutes to complete."
@@ -1,237 +0,0 @@
name: Renovate Dependency Artifacts
on:
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
paths:
- '.bazelversion'
- 'MODULE.bazel'
- 'MODULE.bazel.lock'
- 'maven_install.json'
- 'renovate.json'
- 'ci/github_actions/ensure_bazel_installed.sh'
- 'ci/github_actions/update_llvm_distributions.py'
- '.github/actions/setup-bazel/**'
- '.github/workflows/renovate_dependency_artifacts.yml'
pull_request_target:
types: [opened, reopened, synchronize, ready_for_review]
paths:
- '.bazelversion'
- 'MODULE.bazel'
- 'MODULE.bazel.lock'
- 'maven_install.json'
- 'go.mod'
- 'go.sum'
- 'renovate.json'
- 'ci/github_actions/ensure_bazel_installed.sh'
- 'ci/github_actions/update_llvm_distributions.py'
- '.github/actions/setup-bazel/**'
- '.github/workflows/renovate_dependency_artifacts.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.head.ref }}
cancel-in-progress: true
permissions:
contents: write
pull-requests: read
jobs:
changes:
runs-on: ubuntu-latest
outputs:
bazel: ${{ steps.changed-files.outputs.bazel }}
go: ${{ steps.changed-files.outputs.go }}
update_matrix: ${{ steps.changed-files.outputs.update_matrix }}
steps:
- name: Detect changed dependency files
id: changed-files
uses: actions/github-script@v9
with:
script: |
const files = await github.paginate(
github.rest.pulls.listFiles,
{
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
per_page: 100,
},
);
const changed = files.map((file) => file.filename);
const bazelPatterns = [
/^\.bazelversion$/,
/^MODULE\.bazel$/,
/^MODULE\.bazel\.lock$/,
/^maven_install\.json$/,
/^renovate\.json$/,
/^ci\/github_actions\/ensure_bazel_installed\.sh$/,
/^ci\/github_actions\/update_llvm_distributions\.py$/,
/^\.github\/actions\/setup-bazel\//,
/^\.github\/workflows\/renovate_dependency_artifacts\.yml$/,
];
const goPatterns = [
/^go\.mod$/,
/^go\.sum$/,
/^renovate\.json$/,
/^\.github\/workflows\/renovate_dependency_artifacts\.yml$/,
];
core.setOutput(
'bazel',
changed.some((file) => bazelPatterns.some((pattern) => pattern.test(file))),
);
core.setOutput(
'go',
changed.some((file) => goPatterns.some((pattern) => pattern.test(file))),
);
const include = [];
if (changed.some((file) => bazelPatterns.some((pattern) => pattern.test(file)))) {
include.push({
artifact: 'bazel',
name: 'Bazel lockfiles',
changed_files: 'MODULE.bazel MODULE.bazel.lock maven_install.json',
commit_message: 'Update generated dependency lockfiles',
});
}
if (changed.some((file) => goPatterns.some((pattern) => pattern.test(file)))) {
include.push({
artifact: 'go',
name: 'Go artifacts',
changed_files: 'go.sum',
commit_message: 'Update generated Go artifacts',
});
}
core.setOutput('update_matrix', JSON.stringify({include}));
check-lockfile:
if: >-
github.event_name == 'pull_request' &&
github.event.pull_request.user.login != 'renovate[bot]'
runs-on: [self-hosted, bazel]
permissions:
contents: read
steps:
- name: Checkout PR
uses: actions/checkout@v7
with:
lfs: false
- name: Ensure Bazel installed
uses: ./.github/actions/setup-bazel
- name: Refresh LLVM distribution metadata
env:
GITHUB_TOKEN: ${{ github.token }}
run: python3 ci/github_actions/update_llvm_distributions.py
- name: Update Maven lockfile
env:
REPIN: "1"
run: bazel run @maven//:pin
- name: Refresh Bazel lockfile
run: bazel build --nobuild //src/main/scala/net/eagle0/eagle:eagle_server //src/main/cpp/net/eagle0/shardok:shardok-server
- name: Verify generated lockfiles are current
run: |
if git diff --quiet -- MODULE.bazel MODULE.bazel.lock maven_install.json; then
echo "Generated dependency lockfiles are current"
exit 0
fi
echo "Generated dependency lockfiles are stale. Run the lockfile update commands and commit the result:"
echo " python3 ci/github_actions/update_llvm_distributions.py"
echo " REPIN=1 bazel run @maven//:pin"
echo " bazel build --nobuild //src/main/scala/net/eagle0/eagle:eagle_server //src/main/cpp/net/eagle0/shardok:shardok-server"
git diff -- MODULE.bazel MODULE.bazel.lock maven_install.json
exit 1
update-artifacts:
name: Update ${{ matrix.name }}
needs: changes
if: >-
github.event_name == 'pull_request_target' &&
github.event.pull_request.user.login == 'renovate[bot]' &&
github.event.pull_request.head.repo.full_name == github.repository &&
startsWith(github.event.pull_request.head.ref, 'renovate/') &&
needs.changes.outputs.update_matrix != '{"include":[]}'
runs-on: [self-hosted, bazel]
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.changes.outputs.update_matrix) }}
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 Renovate branch
uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
token: ${{ secrets.RENOVATE_LOCKFILE_TOKEN || github.token }}
lfs: false
- name: Refresh LLVM distribution metadata
if: matrix.artifact == 'bazel'
env:
GITHUB_TOKEN: ${{ github.token }}
run: python3 ci/github_actions/update_llvm_distributions.py
- name: Ensure Bazel installed
if: matrix.artifact == 'bazel'
uses: ./.github/actions/setup-bazel
- name: Update Maven lockfile
if: matrix.artifact == 'bazel'
env:
REPIN: "1"
run: bazel run @maven//:pin
- name: Refresh Bazel lockfile
if: matrix.artifact == 'bazel'
run: bazel build --nobuild //src/main/scala/net/eagle0/eagle:eagle_server //src/main/cpp/net/eagle0/shardok:shardok-server
- name: Set up Go
if: matrix.artifact == 'go'
uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: false
- name: Update Go module sums
if: matrix.artifact == 'go'
run: go mod download all
- name: Commit generated artifact updates
run: |
if git diff --quiet -- ${{ matrix.changed_files }}; then
echo "${{ matrix.name }} are already up to date"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add ${{ matrix.changed_files }}
git commit -m "${{ matrix.commit_message }}"
git push origin "HEAD:${{ github.event.pull_request.head.ref }}"
-189
View File
@@ -1,189 +0,0 @@
name: Repository Cleanup
on:
schedule:
# Registry cleanup: run daily at 03:00 UTC
- cron: '0 3 * * *'
# Artifact cleanup and storage check: run every 6 hours
- cron: '0 */6 * * *'
workflow_dispatch:
inputs:
target:
description: 'Cleanup target'
required: true
default: 'all'
type: choice
options:
- all
- artifacts
- registry
registry_dry_run:
description: 'Dry run registry cleanup'
required: true
default: 'true'
type: boolean
permissions:
contents: read
actions: write
jobs:
cleanup-and-check-artifacts:
if: >-
github.event_name == 'workflow_dispatch' &&
(github.event.inputs.target == 'all' || github.event.inputs.target == 'artifacts') ||
github.event_name == 'schedule' &&
github.event.schedule == '0 */6 * * *'
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
- 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."
cleanup-registry:
if: >-
github.event_name == 'workflow_dispatch' &&
(github.event.inputs.target == 'all' || github.event.inputs.target == 'registry') ||
github.event_name == 'schedule' &&
github.event.schedule == '0 3 * * *'
runs-on: ubuntu-latest
steps:
- name: Install doctl
uses: digitalocean/action-doctl@v2
with:
token: ${{ secrets.DO_REGISTRY_TOKEN }}
- name: Cleanup old images
env:
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.registry_dry_run == 'true' }}
run: |
set -e
RETENTION_DAYS=5
CUTOFF_DATE=$(date -d "-${RETENTION_DAYS} days" +%s)
REGISTRY="eagle0"
echo "Cleaning up images older than ${RETENTION_DAYS} days"
echo "Cutoff date: $(date -d "@${CUTOFF_DATE}" -Iseconds)"
echo "Dry run: ${DRY_RUN}"
echo ""
# List 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')
for REPO in $REPOS; do
echo "=== Processing repository: ${REPO} ==="
# Get all manifests with their tags and dates using JSON output for reliable parsing
MANIFESTS_JSON=$(doctl registry repository list-manifests "${REPO}" --output json 2>/dev/null || echo "[]")
if [ "$MANIFESTS_JSON" = "[]" ] || [ -z "$MANIFESTS_JSON" ]; then
echo " No manifests found"
continue
fi
# Parse JSON and process each manifest
echo "$MANIFESTS_JSON" | jq -r '.[] | "\(.digest) \(.updated_at) \(.tags // [] | join(","))"' | while read -r DIGEST UPDATED_AT TAGS; do
# Skip if no digest or if it doesn't look like a valid digest (sha256:...)
if [ -z "$DIGEST" ] || ! echo "$DIGEST" | grep -q '^sha256:'; then
continue
fi
# Parse the date (ISO 8601 format from JSON)
MANIFEST_DATE=$(date -d "$UPDATED_AT" +%s 2>/dev/null || echo "0")
# Skip protected tags (latest, arm64-latest)
if echo ",$TAGS," | grep -qE ',(latest|arm64-latest),'; then
echo " KEEP: ${DIGEST:0:20}... (protected tag: $TAGS)"
continue
fi
# Check if older than cutoff
if [ "$MANIFEST_DATE" -lt "$CUTOFF_DATE" ]; then
echo " DELETE: ${DIGEST:0:20}... (updated: $UPDATED_AT, tags: $TAGS)"
if [ "$DRY_RUN" != "true" ]; then
doctl registry repository delete-manifest "${REPO}" "$DIGEST" --force
fi
else
echo " KEEP: ${DIGEST:0:20}... (updated: $UPDATED_AT, tags: $TAGS)"
fi
done
echo ""
done
- name: Run garbage collection
if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && github.event.inputs.registry_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"
-153
View File
@@ -1,153 +0,0 @@
name: Server Build
on:
# Main pushes are covered by deploy workflows that build these targets.
pull_request:
paths:
- 'src/main/scala/**'
- 'src/main/cpp/**'
- 'src/main/protobuf/net/eagle0/eagle/**'
- 'src/main/protobuf/net/eagle0/shardok/**'
- 'src/main/protobuf/net/eagle0/common/**'
- 'src/main/go/net/eagle0/build/**'
- 'go.mod'
- 'go.sum'
- 'WORKSPACE'
- 'MODULE.bazel'
- 'MODULE.bazel.lock'
- 'BUILD.bazel'
- '.bazelrc'
- 'ci/github_actions/ensure_bazel_installed.sh'
- 'scripts/build_eagle_ci.sh'
- 'scripts/build_shardok_ci.sh'
- 'scripts/build_shardok_linux_arm64_ci.sh'
- '.github/actions/setup-bazel/**'
- '.github/workflows/server_build.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: read
jobs:
changes:
runs-on: ubuntu-latest
outputs:
eagle: ${{ steps.filter.outputs.eagle }}
shardok: ${{ steps.filter.outputs.shardok }}
build_matrix: ${{ steps.filter.outputs.build_matrix }}
steps:
- name: Detect changed server areas
id: filter
uses: actions/github-script@v9
with:
script: |
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
per_page: 100,
});
const changed = files.map(file => file.filename);
const matches = patterns => changed.some(file =>
patterns.some(pattern =>
pattern.endsWith('/') ? file.startsWith(pattern) : file === pattern
)
);
const sharedPatterns = [
'src/main/protobuf/net/eagle0/common/',
'src/main/go/net/eagle0/build/',
'go.mod',
'go.sum',
'WORKSPACE',
'MODULE.bazel',
'MODULE.bazel.lock',
'BUILD.bazel',
'.bazelrc',
'ci/github_actions/ensure_bazel_installed.sh',
'.github/actions/setup-bazel/',
'.github/workflows/server_build.yml',
];
const eaglePatterns = [
'src/main/scala/',
'src/main/protobuf/net/eagle0/eagle/',
'scripts/build_eagle_ci.sh',
];
const shardokPatterns = [
'src/main/cpp/',
'src/main/protobuf/net/eagle0/shardok/',
'scripts/build_shardok_ci.sh',
'scripts/build_shardok_linux_arm64_ci.sh',
];
const shared = matches(sharedPatterns);
const eagle = shared || matches(eaglePatterns);
const shardok = shared || matches(shardokPatterns);
core.info(`Changed files:\n${changed.join('\n')}`);
core.info(`Run Eagle build: ${eagle}`);
core.info(`Run Shardok build: ${shardok}`);
core.setOutput('eagle', String(eagle));
core.setOutput('shardok', String(shardok));
const include = [];
if (eagle) {
include.push({
name: 'Eagle server',
command: './scripts/build_eagle_ci.sh',
});
}
if (shardok) {
include.push({
name: 'Shardok server',
command: './scripts/build_shardok_ci.sh',
});
include.push({
name: 'Shardok Linux ARM64 server',
command: './scripts/build_shardok_linux_arm64_ci.sh',
});
}
core.setOutput('build_matrix', JSON.stringify({include}));
server-build:
name: Build ${{ matrix.name }}
needs: changes
if: needs.changes.outputs.build_matrix != '{"include":[]}'
runs-on: [self-hosted, bazel]
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.changes.outputs.build_matrix) }}
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@v7
with:
persist-credentials: false
lfs: false
- name: Setup Bazel
uses: ./.github/actions/setup-bazel
- name: Build server
run: ${{ matrix.command }}
+26 -82
View File
@@ -7,15 +7,9 @@ on:
- 'src/main/cpp/**'
- 'src/main/protobuf/net/eagle0/shardok/**'
- 'src/main/protobuf/net/eagle0/common/**'
- 'src/main/go/net/eagle0/build/**'
- 'src/main/resources/net/eagle0/shardok/**'
- 'ci/BUILD.bazel'
- 'ci/github_actions/ensure_bazel_installed.sh'
- '.github/actions/setup-bazel/**'
- 'go.mod'
- 'go.sum'
- 'MODULE.bazel'
- 'MODULE.bazel.lock'
- '.bazelrc'
- '.github/workflows/shardok_arm64_build.yml'
workflow_dispatch:
@@ -39,35 +33,11 @@ jobs:
outputs:
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
steps:
- name: Prepare non-LFS checkout
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig-no-lfs
GIT_CONFIG_NOSYSTEM: "1"
run: |
git config --global --unset-all filter.lfs.process || true
git config --global filter.lfs.smudge "cat"
git config --global filter.lfs.clean "cat"
git config --global filter.lfs.required false
if [ -d ".git" ]; then
git config --local --unset-all filter.lfs.process || true
git config --local filter.lfs.smudge "cat"
git config --local filter.lfs.clean "cat"
git config --local filter.lfs.required false
fi
rm -f .git/hooks/post-checkout .git/hooks/post-merge .git/hooks/pre-push
- name: Checkout repository
uses: actions/checkout@v7
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig-no-lfs
GIT_CONFIG_NOSYSTEM: "1"
uses: actions/checkout@v4
with:
persist-credentials: false
lfs: false
- name: Ensure Bazel installed
uses: ./.github/actions/setup-bazel
- name: Build Shardok ARM64 binary (cross-compile for Linux ARM64)
run: |
set -ex
@@ -75,7 +45,7 @@ jobs:
echo "=== Building shardok-server binary for linux-aarch64 ==="
bazel build \
--platforms=//:linux_arm64 \
--extra_toolchains=@llvm_toolchain_linux_arm64//:cc-toolchain-aarch64-linux \
--extra_toolchains=@llvm_toolchain_linux_arm64//:all \
//src/main/cpp/net/eagle0/shardok:shardok-server
LINUX_BIN="bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server"
@@ -116,10 +86,9 @@ jobs:
run: |
set -ex
# Keep this in sync with the standalone binary build above.
bazel build \
--platforms=//:linux_arm64 \
--extra_toolchains=@llvm_toolchain_linux_arm64//:cc-toolchain-aarch64-linux \
--extra_toolchains=@llvm_toolchain_linux_arm64//:all \
//ci:shardok_server_image_arm64
IMAGE_PATH=$(readlink -f bazel-bin/ci/shardok_server_image_arm64)
@@ -196,9 +165,7 @@ jobs:
echo "Also tagged as: registry.digitalocean.com/eagle0/shardok-server:arm64-latest"
deploy-hetzner:
# Keep this on a self-hosted Mac runner for IPv6 reachability to Hetzner,
# but do not require a Bazel runner slot for artifact transport/deploy work.
runs-on: [self-hosted, macOS, ARM64]
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
@@ -213,55 +180,33 @@ 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 }}"
# Pre-warm NAT64 path to DigitalOcean registry (IPv6-only Hetzner → IPv4 registry)
curl -sf --max-time 10 https://registry.digitalocean.com/v2/ > /dev/null 2>&1 || true
# Pull the new image (retry up to 3 times for NAT64 connectivity flakiness)
for attempt in 1 2 3; do
echo "Pull attempt $attempt..."
if docker pull "${{ needs.build-shardok-arm64.outputs.image_tag }}"; then
break
fi
if [ "$attempt" -eq 3 ]; then
echo "ERROR: docker pull failed after 3 attempts"
exit 1
fi
echo "Pull failed, retrying in 10s..."
sleep 10
done
# Stop and remove any container using port 40042 or named shardok*
docker ps -q --filter "publish=40042" | xargs -r docker stop
@@ -279,12 +224,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
+34
View File
@@ -0,0 +1,34 @@
name: Shardok Build
on:
# Main pushes are covered by shardok_arm64_build.yml which builds the same target
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'
- '.github/workflows/shardok_build.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
build:
runs-on: [self-hosted, bazel]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Shardok server
run: bazel build -c opt //src/main/cpp/net/eagle0/shardok:shardok-server
@@ -1,92 +0,0 @@
name: Shardok Sanitizer Test
on:
push:
branches: ["main"]
paths:
- "src/main/cpp/net/eagle0/shardok/**"
- "src/test/cpp/net/eagle0/shardok/**"
- "src/main/protobuf/net/eagle0/shardok/**"
- "src/main/protobuf/net/eagle0/common/**"
- "WORKSPACE"
- "MODULE.bazel"
- "MODULE.bazel.lock"
- "BUILD.bazel"
- ".bazelrc"
- ".github/actions/setup-bazel/**"
- ".github/workflows/shardok_sanitizer_test.yml"
- "ci/github_actions/ensure_bazel_installed.sh"
- "ci/github_actions/summarize_bazel_bep.py"
pull_request:
paths:
- "src/main/cpp/net/eagle0/shardok/**"
- "src/test/cpp/net/eagle0/shardok/**"
- "src/main/protobuf/net/eagle0/shardok/**"
- "src/main/protobuf/net/eagle0/common/**"
- "WORKSPACE"
- "MODULE.bazel"
- "MODULE.bazel.lock"
- "BUILD.bazel"
- ".bazelrc"
- ".github/actions/setup-bazel/**"
- ".github/workflows/shardok_sanitizer_test.yml"
- "ci/github_actions/ensure_bazel_installed.sh"
- "ci/github_actions/summarize_bazel_bep.py"
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
shardok-sanitizer-test:
name: Shardok ${{ matrix.name }}
runs-on: [self-hosted, bazel]
strategy:
fail-fast: false
matrix:
include:
- name: ASan
config: asan
- name: UBSan
config: ubsan
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@v7
with:
persist-credentials: false
lfs: false
- name: Setup Bazel
uses: ./.github/actions/setup-bazel
- name: Run Shardok sanitizer tests
run: bazel test --config=${{ matrix.config }} --build_event_json_file=${{ matrix.config }}.json //src/test/cpp/net/eagle0/shardok/...
- name: Summarize Bazel build metrics
if: always()
run: python3 ci/github_actions/summarize_bazel_bep.py ${{ matrix.config }}.json
- name: Archive sanitizer test results
if: always()
uses: actions/upload-artifact@v7
with:
name: shardok-${{ matrix.config }}.json
path: ${{ matrix.config }}.json
retention-days: 3
-74
View File
@@ -1,74 +0,0 @@
name: Storage Cleanup
on:
schedule:
# Blob cleanup: run daily at 04:00 UTC
- cron: '0 4 * * *'
# S3 archive cleanup: run weekly on Sunday at 05:00 UTC
- cron: '0 5 * * 0'
workflow_dispatch:
inputs:
target:
description: 'Cleanup target'
required: true
default: 'all'
type: choice
options:
- all
- blob
- archive
permissions:
contents: read
jobs:
cleanup:
name: ${{ matrix.name }}
runs-on: [self-hosted, bazel]
strategy:
fail-fast: false
matrix: ${{ fromJSON(github.event_name == 'workflow_dispatch' && github.event.inputs.target == 'all' && '{"include":[{"name":"Blob cleanup","command":"bazel run //src/main/go/net/eagle0/build/blob_cleanup:blob_cleanup -- --min-age=1h"},{"name":"S3 archive cleanup","command":"bazel run //src/main/go/net/eagle0/build/s3_archive_cleanup:s3_archive_cleanup -- --min-age=720h"}]}' || github.event_name == 'workflow_dispatch' && github.event.inputs.target == 'archive' && '{"include":[{"name":"S3 archive cleanup","command":"bazel run //src/main/go/net/eagle0/build/s3_archive_cleanup:s3_archive_cleanup -- --min-age=720h"}]}' || github.event_name == 'schedule' && github.event.schedule == '0 5 * * 0' && '{"include":[{"name":"S3 archive cleanup","command":"bazel run //src/main/go/net/eagle0/build/s3_archive_cleanup:s3_archive_cleanup -- --min-age=720h"}]}' || '{"include":[{"name":"Blob cleanup","command":"bazel run //src/main/go/net/eagle0/build/blob_cleanup:blob_cleanup -- --min-age=1h"}]}') }}
steps:
- name: Prepare non-LFS checkout
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig-no-lfs
GIT_CONFIG_NOSYSTEM: "1"
run: |
git config --global --unset-all filter.lfs.process || true
git config --global filter.lfs.smudge "cat"
git config --global filter.lfs.clean "cat"
git config --global filter.lfs.required false
if [ -d ".git" ]; then
git config --local --unset-all filter.lfs.process || true
git config --local filter.lfs.smudge "cat"
git config --local filter.lfs.clean "cat"
git config --local filter.lfs.required false
fi
rm -f .git/hooks/post-checkout .git/hooks/post-merge .git/hooks/pre-push
- uses: actions/checkout@v7
env:
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig-no-lfs
GIT_CONFIG_NOSYSTEM: "1"
with:
persist-credentials: false
lfs: false
clean: false
- name: Ensure Bazel installed
uses: ./.github/actions/setup-bazel
- name: Run blob cleanup
if: matrix.name == 'Blob cleanup'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: ${{ matrix.command }}
- name: Run S3 archive cleanup
if: matrix.name == 'S3 archive cleanup'
env:
DO_SPACES_ACCESS_KEY: ${{ secrets.DO_SPACES_ACCESS_KEY }}
DO_SPACES_SECRET_KEY: ${{ secrets.DO_SPACES_SECRET_KEY }}
run: ${{ matrix.command }}
+23 -300
View File
@@ -14,18 +14,11 @@ on:
- "scripts/build_protos.sh"
- "scripts/build_plugins.sh"
- "scripts/build_windows_plugin.sh"
- "scripts/sync_bazel_xcode.sh"
- "ci/github_actions/build_unity.sh"
- "ci/github_actions/test_unity_editmode.sh"
- "ci/github_actions/ensure_bazel_installed.sh"
- ".github/actions/setup-bazel/**"
- "ci/github_actions/detect_addressables_changes.sh"
- "ci/github_actions/upload_addressables.sh"
- "ci/github_actions/ensure_unity_installed.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
- "MODULE.bazel"
- "MODULE.bazel.lock"
- ".bazelrc"
- "WORKSPACE"
workflow_dispatch:
pull_request:
@@ -40,151 +33,29 @@ on:
- "scripts/build_protos.sh"
- "scripts/build_plugins.sh"
- "scripts/build_windows_plugin.sh"
- "scripts/sync_bazel_xcode.sh"
- "ci/github_actions/build_unity.sh"
- "ci/github_actions/test_unity_editmode.sh"
- "ci/github_actions/ensure_bazel_installed.sh"
- ".github/actions/setup-bazel/**"
- "ci/github_actions/detect_addressables_changes.sh"
- "ci/github_actions/upload_addressables.sh"
- "ci/github_actions/ensure_unity_installed.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
- "MODULE.bazel"
- "MODULE.bazel.lock"
- ".bazelrc"
- "WORKSPACE"
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
actions: write
contents: read
env:
# Runner-specific build directory to allow parallel builds on multiple runners
EAGLE0_BUILD_DIR: /tmp/eagle0-${{ github.run_id }}-${{ github.job }}
EAGLE0_BUILD_DIR: /tmp/eagle0-${{ github.run_id }}
jobs:
unity-editmode-tests:
runs-on: [self-hosted, macOS, unity-windows]
concurrency:
group: ${{ github.workflow }}-tests-${{ github.head_ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
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@v7
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: 2
- name: Clean stale files
run: |
git clean -ffd
UNITY_ROOT="src/main/csharp/net/eagle0/clients/unity/eagle0"
# These ignored proto outputs are mutually exclusive across proto-generation
# layouts. Clean both before regenerating to avoid stale DLL/source duplicates
# when a self-hosted runner switches between main and PR branches.
rm -rf "$UNITY_ROOT/Assets/GeneratedProtos"
rm -rf "$UNITY_ROOT/Assets/Plugins/Eagle0Protos"
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 .NET SDK installed
uses: actions/setup-dotnet@v5
with:
dotnet-version: '8.0.x'
- name: Ensure Bazel installed
uses: ./.github/actions/setup-bazel
- name: Sync Bazel Xcode configuration
run: ./scripts/sync_bazel_xcode.sh
- name: Run Unity EditMode tests
run: ./ci/github_actions/test_unity_editmode.sh
- 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 }}"
windows-unity:
runs-on: [self-hosted, macOS, unity-windows]
outputs:
addressables_changed: ${{ steps.addressables.outputs.changed }}
deployed_version: ${{ steps.get-version.outputs.deployed_version }}
concurrency:
group: ${{ github.workflow }}-build-${{ github.head_ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
steps:
- name: Prune stale PR refs
@@ -198,198 +69,58 @@ jobs:
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@v7
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
fetch-depth: 2 # Keep the previous main commit available for Addressables change detection
- name: Clean stale files
run: |
git clean -ffd
UNITY_ROOT="src/main/csharp/net/eagle0/clients/unity/eagle0"
# These ignored proto outputs are mutually exclusive across proto-generation
# layouts. Clean both before regenerating to avoid stale DLL/source duplicates
# when a self-hosted runner switches between main and PR branches.
rm -rf "$UNITY_ROOT/Assets/GeneratedProtos"
rm -rf "$UNITY_ROOT/Assets/Plugins/Eagle0Protos"
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"
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/"
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/"
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"
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 .NET SDK installed
uses: actions/setup-dotnet@v5
with:
dotnet-version: '8.0.x'
- name: Ensure Bazel installed
uses: ./.github/actions/setup-bazel
- name: Sync Bazel Xcode configuration
run: ./scripts/sync_bazel_xcode.sh
- name: Detect Addressables changes
id: addressables
if: success()
shell: bash
run: |
set -euo pipefail
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
BASE_SHA="${{ github.event.pull_request.base.sha }}"
else
BASE_SHA="${{ github.event.before }}"
fi
./ci/github_actions/detect_addressables_changes.sh "$BASE_SHA" "${{ github.sha }}"
- name: Build Windows unity
run: ./ci/github_actions/build_unity.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0WIN" true "${{ steps.addressables.outputs.changed }}"
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
run: 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: Check if run is still latest main for Addressables
id: check-addressables-latest
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && steps.addressables.outputs.changed == 'true'
run: |
LATEST_SHA=$(curl -fsSL \
-H "Authorization: Bearer ${{ github.token }}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${{ github.repository }}/commits/main" \
| python3 -c 'import json, sys; print(json.load(sys.stdin)["sha"])')
if [[ "$LATEST_SHA" != "${{ github.sha }}" ]]; then
echo "Skipping Addressables upload because ${{ github.sha }} is no longer latest main ($LATEST_SHA is latest)"
echo "should_upload=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "should_upload=true" >> "$GITHUB_OUTPUT"
- name: Wait for EditMode tests before publishing
- name: Upload Addressables to CDN
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
env:
GH_TOKEN: ${{ github.token }}
run: |
deadline=$((SECONDS + 600))
while [ "$SECONDS" -lt "$deadline" ]; do
result=$(curl -fsSL \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs" \
| python3 -c 'import json, sys; job = next(j for j in json.load(sys.stdin)["jobs"] if j["name"] == "unity-editmode-tests"); print("{} {}".format(job["status"], job["conclusion"] or ""))')
status="${result%% *}"
conclusion="${result#* }"
if [ "$status" = "completed" ]; then
if [ "$conclusion" = "success" ]; then
echo "EditMode tests passed; publishing may continue."
exit 0
fi
echo "EditMode tests completed with conclusion: $conclusion"
exit 1
fi
echo "EditMode tests are still $status; waiting..."
sleep 5
done
echo "Timed out waiting for EditMode tests."
exit 1
- name: Check if run is still latest main for publish
id: check-publish-latest
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
run: |
LATEST_SHA=$(curl -fsSL \
-H "Authorization: Bearer ${{ github.token }}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${{ github.repository }}/commits/main" \
| python3 -c 'import json, sys; print(json.load(sys.stdin)["sha"])')
if [[ "$LATEST_SHA" != "${{ github.sha }}" ]]; then
echo "Skipping publish because ${{ github.sha }} is no longer latest main ($LATEST_SHA is latest)"
echo "should_publish=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "should_publish=true" >> "$GITHUB_OUTPUT"
- name: Stage Windows blobs for publish
if: success() && steps.check-publish-latest.outputs.should_publish == 'true'
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 -- --skip-previous-manifest "${{ env.EAGLE0_BUILD_DIR }}/eagle0WIN" "${{ env.EAGLE0_BUILD_DIR }}/unity_manifest.txt"
- name: Upload Windows Addressables to CDN
if: success() && steps.check-publish-latest.outputs.should_publish == 'true' && steps.check-addressables-latest.outputs.should_upload == 'true'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: ./ci/github_actions/upload_addressables.sh StandaloneWindows64
- name: Publish previous Windows manifest
if: success() && steps.check-publish-latest.outputs.should_publish == 'true'
- name: Deploy Windows unity
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: bazel run //src/main/go/net/eagle0/build/unity3d_windows_build_handler:unity3d_windows_build_handler -- --publish-previous-manifest "${{ env.EAGLE0_BUILD_DIR }}/unity_manifest.txt"
run: bazel run //src/main/go/net/eagle0/build/unity3d_windows_build_handler:unity3d_windows_build_handler -- "${{ env.EAGLE0_BUILD_DIR }}/eagle0WIN" "/tmp/unity_manifest.txt"
- name: Update unified manifest
if: success() && steps.check-publish-latest.outputs.should_publish == 'true'
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 }}
@@ -407,33 +138,25 @@ jobs:
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 "${{ env.EAGLE0_BUILD_DIR }}/unity_manifest.txt" $SIGNING_ARGS
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- unity3d-v2 /tmp/unity_manifest.txt $SIGNING_ARGS
# Cleanup
rm -f /tmp/manifest_signing_key
- name: Export deployed version
id: get-version
if: success() && steps.check-publish-latest.outputs.should_publish == 'true'
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
run: |
VERSION=$(grep "^version=" "${{ env.EAGLE0_BUILD_DIR }}/unity_manifest.txt" | cut -d= -f2 || date +%Y.%m.%d)
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 Addressables build reports
if: (success() || failure()) && steps.addressables.outputs.changed == 'true'
uses: actions/upload-artifact@v7
with:
name: windows-addressables-build-reports
path: src/main/csharp/net/eagle0/clients/unity/eagle0/Library/com.unity.addressables/BuildReports
if-no-files-found: ignore
retention-days: 3
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
@@ -449,4 +172,4 @@ jobs:
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)"
--fail --silent --show-error || echo "Warning: Failed to notify clients (non-fatal)"
-4
View File
@@ -23,7 +23,6 @@ bazel-bin
bazel-eagle0*
bazel-out
bazel-testlogs
.bazelrc.local
.bazelrc.xcode
.ijwb
.clwb
@@ -40,10 +39,7 @@ scripts/refresh_name_layers/refresh_name_layers.zip
api_keys.txt
src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/GeneratedProtos/
src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/Eagle0Protos.meta
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
+2 -2
View File
@@ -34,8 +34,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:
-445
View File
@@ -1,445 +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 UNITY PROCESS RULES (NEVER VIOLATE)
**NEVER kill Unity without asking the user first.** The user might be actively using the Unity Editor. Do not kill,
force-quit, terminate, or otherwise stop Unity processes unless the user explicitly approves that specific action.
**NEVER change Unity's default/open scene as part of running checks.** Unity batchmode or editor validation may write
scene-selection churn such as `ProjectSettings/EditorBuildSettings.asset`, `ProjectSettings/SceneTemplateSettings.json`,
or scene files simply because a different scene was open or loaded. Treat those as unintended local environment changes:
do not stage them, and restore them before committing unless the user explicitly asked to change scenes/build settings.
## 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.
## Commit History During Review
Prefer new follow-up commits over amending existing commits once a PR is open and the user is actively reviewing or
testing it. This preserves working interim states so the user can inspect, compare, or return to a known-good point
while improvements continue.
Use `git commit --amend` and force-push only when the user explicitly asks for history cleanup, when fixing metadata
before review has started, or when repairing a local commit that has not been pushed. If you believe squashing is the
right choice despite an open review, explain why and ask first.
## 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.
For PRs expected to trigger substantial Bazel work in CI, run the relevant Bazel builds and tests locally too. This
workstation is fast, and local Bazel runs can help hydrate the remote cache for the CI builders while still giving
earlier signal on failures.
## Cleanup PR Batching
When making mechanical cleanup changes, group 3-5 files per PR when the files are receiving the same kind of change
and can be reviewed as one pattern. Do not open one PR per file for nearly identical few-line cleanups, such as replacing
the same unsafe accessor pattern with the same contextual failure pattern across multiple Scala files.
Keep separate PRs for changes that involve different semantics, materially different risk, unrelated subsystems, or
files whose tests/validation strategy makes them better reviewed independently.
## GitHub CLI PR Bodies
When creating or editing PR descriptions with multiline bodies, write the body to a temporary markdown file and pass it
with `gh pr create --body-file <path>` or `gh pr edit <number> --body-file <path>`. Do not use multiline `$'...'`
shell quoting for PR bodies; Codex's permission matching may treat that as a complex shell command instead of a clean
`gh pr` invocation, causing unnecessary approval prompts.
Write these temporary body files under `/private/tmp/eagle0-pr-bodies/`, creating that directory first when needed. This
directory is the canonical writable scratch location for PR descriptions. Do not write PR body files to protected paths,
repo-tracked paths, or ad hoc locations that might require additional user permission.
Use ordinary Bash file-writing commands for these temporary PR body files. First run a standalone command to create the
directory. Then run a standalone `printf` command that writes to `/private/tmp/eagle0-pr-bodies/<name>.md`. **Do not use
`apply_patch` for temporary PR body files**, because patch tools are for tracked workspace edits and may incorrectly
request user approval for scratch paths outside the repository.
Write other scratch files needed for commands under `/private/tmp/eagle0-scratch/`, creating that directory first when
needed. Do not use protected paths that require additional user permission just to create or edit temporary files.
---
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 using the same target as GitHub Actions
./scripts/build_eagle_ci.sh
# Build Shardok server using the same target and flags as GitHub Actions
./scripts/build_shardok_ci.sh
# Warm the remote cache for the main GitHub Actions Bazel builds
./scripts/hydrate_bazel_remote_cache.sh
# 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.**
### Bazel Cache Hydration
When making changes that will trigger GitHub Actions Bazel builds, run the matching CI script instead of hand-writing
`bazel build` commands. The scripts keep local cache hydration aligned with CI targets, compilation modes, and flags:
```bash
./scripts/build_eagle_ci.sh
./scripts/build_shardok_ci.sh
./scripts/hydrate_bazel_remote_cache.sh
```
Use direct `bazel build` commands only for targeted debugging where CI cache hydration is not the goal.
### 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)
./scripts/build_shardok_ci.sh
# 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.5 (6000.5.0f1) 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
-3
View File
@@ -1,6 +1,5 @@
load("@bazel_gazelle//:def.bzl", "gazelle")
load("@io_bazel_rules_go//go:def.bzl", "nogo")
load("@rules_shell//shell:sh_test.bzl", "sh_test")
package(default_visibility = ["//visibility:public"])
@@ -11,7 +10,6 @@ platform(
"@platforms//os:linux",
"@platforms//cpu:x86_64",
],
missing_toolchain_error = "No Linux x86_64 toolchain matched. Docker/Eagle image builds should pass --platforms=//:linux_x86_64 and --extra_toolchains=@llvm_toolchain_linux//:cc-toolchain-x86_64-linux when compiling C++ code.",
)
# Platform for cross-compiling to Linux ARM64
@@ -21,7 +19,6 @@ platform(
"@platforms//os:linux",
"@platforms//cpu:aarch64",
],
missing_toolchain_error = "No Linux ARM64 toolchain matched. Shardok ARM64 builds should pass --platforms=//:linux_arm64 and --extra_toolchains=@llvm_toolchain_linux_arm64//:cc-toolchain-aarch64-linux.",
)
gazelle(name = "gazelle")
+3 -5
View File
@@ -4,11 +4,9 @@
**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 use `git -C`.** Instead, cd to the repo root and run git commands from there.
**NEVER push directly to main/master.** No exceptions. Not for "small changes." Not for docs. Not ever.
@@ -228,7 +226,7 @@ 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.10f1) 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/`
@@ -352,4 +350,4 @@ dotty.tools.dotc.core.MissingType: Cannot resolve reference to type net.eagle0.e
- 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
+63 -136
View File
@@ -3,54 +3,25 @@ module(name = "net_eagle0")
# Version constants
SCALA_VERSION = "3.7.4"
NETTY_VERSION = "4.1.135.Final"
NETTY_VERSION = "4.1.127.Final"
SCALAPB_VERSION = "1.0.0-alpha.5"
SCALAPB_VERSION = "1.0.0-alpha.1"
SCALAPB_JSON4S_VERSION = "1.0.0-alpha.1"
AWS_SDK_VERSION = "2.46.9"
SLF4J_VERSION = "2.0.18"
AWS_SDK_VERSION = "2.41.18"
#
# Core Build Tools
#
bazel_dep(name = "bazel_skylib", version = "1.9.0")
bazel_dep(name = "aspect_rules_esbuild", version = "0.26.0")
bazel_dep(name = "aspect_rules_js", version = "3.2.2")
bazel_dep(name = "bazel_jar_jar", version = "0.1.15", repo_name = "bazel_jar_jar")
bazel_dep(name = "platforms", version = "1.1.0")
bazel_dep(name = "rules_dotnet", version = "0.22.1")
bazel_dep(name = "rules_foreign_cc", version = "0.15.1")
bazel_dep(name = "rules_android", version = "0.7.3")
bazel_dep(name = "rules_nodejs", version = "6.7.5")
bazel_dep(name = "platforms", version = "1.0.0")
bazel_dep(name = "rules_pkg", version = "1.2.0")
bazel_dep(name = "rules_shell", version = "0.8.0")
multiple_version_override(
module_name = "aspect_rules_js",
versions = [
"2.3.8",
"3.2.2",
],
)
npm = use_extension("@aspect_rules_js//npm:extensions.bzl", "npm")
npm.npm_translate_lock(
name = "eagle0pedia_npm",
pnpm_lock = "//docs/eagle0pedia:pnpm-lock.yaml",
run_lifecycle_hooks = False,
update_pnpm_lock = False,
)
use_repo(npm, "eagle0pedia_npm")
#
# Language Support - Scala
#
bazel_dep(name = "rules_scala", version = "7.2.6")
bazel_dep(name = "rules_scala", version = "7.2.1")
scala_config = use_extension(
"@rules_scala//scala/extensions:config.bzl",
@@ -70,31 +41,21 @@ scala_deps.scala_proto()
# Language Support - C++
#
bazel_dep(name = "toolchains_llvm", version = "1.8.0")
bazel_dep(name = "toolchains_llvm", version = "1.6.0")
llvm = use_extension("@toolchains_llvm//toolchain/extensions:llvm.bzl", "llvm")
LLVM_VERSION = "22.1.8"
LLVM_DISTRIBUTIONS = {
"LLVM-22.1.8-Linux-ARM64.tar.xz": "805efad2bb91cb4967fa569e0881d10c0f69c04461cf671cccbae19f547acc34",
"LLVM-22.1.8-Linux-X64.tar.xz": "df0e1ecf16caf3489a272a5eea4eec9b0d82878f6477fa309504f918a0006384",
"LLVM-22.1.8-macOS-ARM64.tar.xz": "f260f4f7c0d430828a81ae8a3826a1d63fc0963ec2459489308cc23b1f7eab4f",
}
# Native toolchain (macOS -> macOS, Linux -> Linux)
llvm.toolchain(
name = "llvm_toolchain",
extra_llvm_distributions = LLVM_DISTRIBUTIONS,
llvm_version = LLVM_VERSION,
llvm_version = "20.1.4",
)
# Cross-compilation toolchain (macOS -> Linux x86_64)
# Uses the same LLVM distribution but with a Linux sysroot
llvm.toolchain(
name = "llvm_toolchain_linux",
extra_llvm_distributions = LLVM_DISTRIBUTIONS,
llvm_version = LLVM_VERSION,
llvm_version = "20.1.4",
)
# Linux x86_64 sysroot for cross-compilation
@@ -107,8 +68,7 @@ llvm.sysroot(
# Cross-compilation toolchain (macOS -> Linux ARM64)
llvm.toolchain(
name = "llvm_toolchain_linux_arm64",
extra_llvm_distributions = LLVM_DISTRIBUTIONS,
llvm_version = LLVM_VERSION,
llvm_version = "20.1.4",
)
# Linux ARM64 sysroot for cross-compilation
@@ -117,6 +77,7 @@ llvm.sysroot(
label = "@linux_sysroot_arm64//sysroot",
targets = ["linux-aarch64"],
)
use_repo(llvm, "llvm_toolchain", "llvm_toolchain_linux", "llvm_toolchain_linux_arm64")
# Download the Linux sysroots (Ubuntu 24.04 Noble for C++23 support)
@@ -142,11 +103,11 @@ sysroot(
# Language Support - Go
#
bazel_dep(name = "rules_go", version = "0.61.1", repo_name = "io_bazel_rules_go")
bazel_dep(name = "gazelle", version = "0.51.3", repo_name = "bazel_gazelle")
bazel_dep(name = "rules_go", version = "0.59.0", repo_name = "io_bazel_rules_go")
bazel_dep(name = "gazelle", version = "0.47.0", repo_name = "bazel_gazelle")
go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk")
go_sdk.download(version = "1.25.11")
go_sdk.download(version = "1.23.3")
use_repo(go_sdk, "go_default_sdk")
go_deps = use_extension("@bazel_gazelle//:extensions.bzl", "go_deps")
@@ -169,7 +130,7 @@ use_repo(
# Language Support - Rust
#
bazel_dep(name = "rules_rust", version = "0.71.3")
bazel_dep(name = "rules_rust", version = "0.68.1")
rust = use_extension("@rules_rust//rust:extensions.bzl", "rust")
rust.toolchain(edition = "2021")
@@ -177,7 +138,7 @@ use_repo(rust, "rust_toolchains")
register_toolchains("@rust_toolchains//:all")
crate = use_extension("@rules_rust//crate_universe:extensions.bzl", "crate")
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",
@@ -189,16 +150,8 @@ use_repo(crate, "map_generator_crates")
# Platform Support - Apple/iOS
#
bazel_dep(name = "apple_support", version = "2.7.0", repo_name = "build_bazel_apple_support")
bazel_dep(name = "rules_apple", version = "4.5.3", repo_name = "build_bazel_rules_apple")
multiple_version_override(
module_name = "rules_swift",
versions = [
"2.1.1",
"3.5.0",
],
)
bazel_dep(name = "apple_support", version = "1.24.1", repo_name = "build_bazel_apple_support")
bazel_dep(name = "rules_apple", version = "4.3.3", repo_name = "build_bazel_rules_apple")
# Register Apple CC toolchain for Objective-C compilation
apple_cc_configure = use_extension(
@@ -211,17 +164,11 @@ use_repo(apple_cc_configure, "local_config_apple_cc", "local_config_apple_cc_too
# Protocol Buffers & RPC
#
bazel_dep(name = "protobuf", version = "35.1", repo_name = "com_google_protobuf")
single_version_override(
module_name = "protobuf",
patch_strip = 1,
patches = ["//third_party/protobuf/patches:rust_crates_dev_dependency.patch"],
version = "35.1",
)
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/oss/toolchains/prebuilt:protoc_extension.bzl", "protoc")
prebuilt_protoc = use_extension("@com_google_protobuf//bazel/private:prebuilt_protoc_extension.bzl", "protoc")
use_repo(
prebuilt_protoc,
"prebuilt_protoc.linux_aarch_64",
@@ -236,18 +183,21 @@ use_repo(
)
bazel_dep(name = "rules_proto", version = "7.1.0")
bazel_dep(name = "rules_cc", version = "0.2.22")
bazel_dep(name = "grpc", version = "1.82.0")
bazel_dep(name = "rules_cc", version = "0.2.14")
bazel_dep(name = "grpc", version = "1.78.0")
# gRPC 1.81.1's generated upb module map trips Clang's layering check in the
# Linux ARM64 cross-toolchain even though the normal Bazel deps are present.
# 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/grpc/patches:disable_xds_client_layering_check.patch"],
patches = ["//third_party/patches:grpc_fix_python_version.patch"],
version = "1.78.0",
)
bazel_dep(name = "grpc-java", version = "1.82.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")
@@ -255,13 +205,13 @@ bazel_dep(name = "flatbuffers", version = "25.12.19")
# Testing
#
bazel_dep(name = "googletest", version = "1.17.0.bcr.2")
bazel_dep(name = "googletest", version = "1.17.0")
#
# Container Images (OCI)
#
bazel_dep(name = "rules_oci", version = "2.3.0")
bazel_dep(name = "rules_oci", version = "2.2.7")
bazel_dep(name = "aspect_bazel_lib", version = "2.22.5")
oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
@@ -272,19 +222,19 @@ oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
# curl -s -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json" "https://registry-1.docker.io/v2/library/eclipse-temurin/manifests/25-jre" | python3 -c "import json,sys; m=json.load(sys.stdin); [print(p['digest']) for p in m.get('manifests',[]) if p['platform']['architecture']=='amd64']"
oci.pull(
name = "eclipse_temurin_25_jre",
digest = "sha256:a0053c48b1a74412b14a111d6fd547799f8a6a5c77e2318f698b996ec899b0ce",
digest = "sha256:98236ffdfb61cff3fc4f8e40e5b80460a5a4c35a0f56a5b4182a926844a7db49",
image = "docker.io/library/eclipse-temurin",
platforms = ["linux/amd64"],
tag = "26-jre",
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:8e0469892385b62feab689f211fde7eeeb6537418c8fbe613147427b53870ec3",
digest = "sha256:bc2d562355813350f47bc472b9721b0b84761866671ad95d9a9f1151fa69da6e",
image = "docker.io/library/eclipse-temurin",
platforms = ["linux/amd64"],
tag = "26-jdk",
tag = "25-jdk",
)
# Base image for Shardok (Ubuntu 24.04 for C++ runtime)
@@ -295,16 +245,15 @@ oci.pull(
"linux/amd64",
"linux/arm64/v8",
],
tag = "26.04",
tag = "24.04",
)
# Base image for Admin Server (Alpine for lightweight Go binary)
oci.pull(
name = "alpine_linux",
digest = "sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b",
image = "docker.io/library/alpine",
platforms = ["linux/amd64"],
tag = "3.24",
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")
@@ -312,8 +261,8 @@ use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_25_jd
# Java/Scala Dependencies
#
bazel_dep(name = "rules_java", version = "9.6.1")
bazel_dep(name = "rules_jvm_external", version = "7.0")
bazel_dep(name = "rules_java", version = "9.3.0")
bazel_dep(name = "rules_jvm_external", version = "6.10")
maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")
maven.install(
@@ -332,11 +281,11 @@ maven.install(
# ScalaPB
"com.thesamet.scalapb:lenses_3:%s" % SCALAPB_VERSION,
"com.thesamet.scalapb:scalapb-json4s_3:%s" % SCALAPB_JSON4S_VERSION,
"com.thesamet.scalapb:scalapb-json4s_3:%s" % SCALAPB_VERSION,
"com.thesamet.scalapb:scalapb-runtime_3:%s" % SCALAPB_VERSION,
"com.thesamet.scalapb:scalapb-runtime-grpc_3:%s" % SCALAPB_VERSION,
"com.thesamet.scalapb:compilerplugin_3:%s" % SCALAPB_VERSION,
"com.thesamet.scalapb:protoc-bridge_3:0.9.10",
"com.thesamet.scalapb:protoc-bridge_3:0.9.9",
# JSON
"org.json4s:json4s-ast_3:4.1.0-M8",
@@ -344,10 +293,7 @@ maven.install(
"org.json4s:json4s-native_3:4.1.0-M8",
# Testing
"org.scalamock:scalamock_3:7.5.5",
# Developer tools
"org.scalameta:scalafmt-cli_2.13:3.11.3",
"org.scalamock:scalamock_3:7.4.1",
# AWS SDK
"software.amazon.awssdk:s3-transfer-manager:%s" % AWS_SDK_VERSION,
@@ -359,74 +305,58 @@ maven.install(
"software.amazon.awssdk:http-client-spi:%s" % AWS_SDK_VERSION,
# AWS Lambda
"com.amazonaws:aws-lambda-java-core:1.4.0",
"com.amazonaws:aws-lambda-java-events:3.16.1",
"com.amazonaws:aws-lambda-java-core:1.2.3",
"com.amazonaws:aws-lambda-java-events:3.13.0",
# Logging
"org.slf4j:slf4j-api:%s" % SLF4J_VERSION,
"org.slf4j:slf4j-simple:%s" % SLF4J_VERSION,
"org.slf4j:slf4j-api:2.0.16",
"org.slf4j:slf4j-simple:2.0.16",
# Other
"org.reactivestreams:reactive-streams:1.0.4",
"javax.xml.bind:jaxb-api:2.3.1",
# OkHttp (for SSE with read timeout support, OAuth HTTP calls)
"com.squareup.okhttp3:okhttp-jvm:5.4.0",
"com.squareup.okhttp3:okhttp-sse:5.4.0",
"com.squareup.okhttp3:okhttp:4.12.0",
"com.squareup.okhttp3:okhttp-sse:4.12.0",
# JWT (for OAuth token handling)
"com.nimbusds:nimbus-jose-jwt:10.9.1",
"com.nimbusds:nimbus-jose-jwt:9.37.3",
# Error tracking
"io.sentry:sentry:8.48.0",
"io.sentry:sentry:8.31.0",
# SQLite for client text storage
"org.xerial:sqlite-jdbc:3.53.2.0",
# Postgres for production history benchmarking and migration work
"com.zaxxer:HikariCP:7.1.0",
"org.postgresql:postgresql:42.7.13",
"org.xerial:sqlite-jdbc:3.46.1.0",
],
duplicate_version_warning = "error",
fail_if_repin_required = True,
known_contributing_modules = [
"grpc-java",
"protobuf",
],
lock_file = "//:maven_install.json",
repositories = [
"https://repo.maven.apache.org/maven2",
"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 = SCALA_VERSION,
)
# 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.14.0",
version = "2.11.0",
)
maven.artifact(
artifact = "error_prone_annotations",
force_version = True,
group = "com.google.errorprone",
version = "2.50.0",
version = "2.30.0",
)
maven.artifact(
artifact = "guava",
force_version = True,
group = "com.google.guava",
version = "33.6.0-jre",
version = "33.3.1-android",
)
use_repo(maven, "maven", "unpinned_maven")
#
@@ -434,7 +364,6 @@ use_repo(maven, "maven", "unpinned_maven")
#
http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file")
# GTL (for parallel_hashmap)
@@ -445,8 +374,6 @@ GTL_SHA = "1969c45dd76eac0dd87e9e2b65cffe358617f4fe1bcd203f72f427742537913a"
http_archive(
name = "gtl",
build_file_content = """
load("@rules_cc//cc:defs.bzl", "cc_library")
cc_library(
name = "gtl",
hdrs = glob(["include/gtl/*.hpp"]),
@@ -474,12 +401,12 @@ http_archive(
)
# Sparkle framework for macOS auto-updates
SPARKLE_VERSION = "2.9.3"
SPARKLE_VERSION = "2.6.4"
http_archive(
name = "sparkle",
build_file = "//src/main/objc/net/eagle0/clients/unity/sparkle/external:BUILD.sparkle",
sha256 = "74a07da821f92b79310009954c0e15f350173374a3abe39095b4fc5096916be6",
sha256 = "50612a06038abc931f16011d7903b8326a362c1074dabccb718404ce8e585f0b",
strip_prefix = "",
url = "https://github.com/sparkle-project/Sparkle/releases/download/%s/Sparkle-%s.tar.xz" % (SPARKLE_VERSION, SPARKLE_VERSION),
)
@@ -489,24 +416,24 @@ http_archive(
# Fallback: busybox.net (can be unreliable/slow)
http_file(
name = "busybox_x86_64",
downloaded_file_path = "busybox",
executable = True,
sha256 = "6e123e7f3202a8c1e9b1f94d8941580a25135382b99e8d3e34fb858bba311348",
urls = [
"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/busybox/busybox-1.35.0-x86_64-linux-musl",
"https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox",
],
downloaded_file_path = "busybox",
executable = True,
)
http_file(
name = "busybox_aarch64",
downloaded_file_path = "busybox",
executable = True,
sha256 = "141adb1b625a6f44c4b114f76b4387b4ea4f7ab802b88eb40e0d2f6adcccb1c3",
urls = [
# TODO: Upload aarch64 binary to GitHub release when needed
"https://busybox.net/downloads/binaries/1.35.0-aarch64-linux-musl/busybox",
],
downloaded_file_path = "busybox",
executable = True,
)
# LLVM MinGW toolchain for Windows cross-compilation from macOS
@@ -580,7 +507,7 @@ register_toolchains("@local_config_apple_cc_toolchains//:all")
# Set dev_dependency so we can turn this off for swift MacOS builds
register_toolchains(
"@llvm_toolchain//:all",
"@llvm_toolchain_linux//:cc-toolchain-x86_64-linux",
"@llvm_toolchain_linux_arm64//:cc-toolchain-aarch64-linux",
"@llvm_toolchain_linux//:all",
"@llvm_toolchain_linux_arm64//:all",
dev_dependency = True,
)
+3229 -1114
View File
File diff suppressed because one or more lines are too long
+9 -40
View File
@@ -1,6 +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)
@@ -51,37 +50,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
@@ -109,11 +82,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 +91,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",
@@ -212,9 +181,9 @@ oci_push(
)
#
# Shardok Server ARM64 Docker Image (for the Hetzner deployment host)
# Shardok Server ARM64 Docker Image (for Hetzner on-demand compute)
#
# Build: bazel build //ci:shardok_server_image_arm64 --platforms=//:linux_arm64 --extra_toolchains=@llvm_toolchain_linux_arm64//:cc-toolchain-aarch64-linux
# Build: bazel build //ci:shardok_server_image_arm64 --platforms=//:linux_arm64 --extra_toolchains=@llvm_toolchain_linux_arm64//:all
# Load: bazel run //ci:shardok_server_load_arm64
# Push: bazel run //ci:shardok_server_push_arm64
#
+3 -1
View File
@@ -5,7 +5,8 @@ set -x
COMMAND_LOG="$(bazel info command_log)"
bazel build \
//src/main/cpp/net/eagle0/shardok:shardok-server
//src/main/cpp/net/eagle0/shardok:shardok-server \
//src/main/cpp/net/eagle0/shardok:shardok-inspector
STATUS="$?"
if [ $STATUS -eq 0 ]
@@ -25,6 +26,7 @@ echo "Setting up Shardok files..."
/bin/cp -R ./bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server ./shardok/
/bin/mkdir -p ./shardok/shardok-server.runfiles/net_eagle0/src/main/
/bin/cp -R ./bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server.runfiles/net_eagle0/src/main/resources ./shardok/shardok-server.runfiles/net_eagle0/src/main/
/bin/cp -R ./bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-inspector ./shardok/
echo "tar & zip shardok files..."
/bin/tar cfh - shardok | /usr/bin/pigz > ./deploy/shardok.tar.gz
+13 -39
View File
@@ -40,51 +40,25 @@ 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"
# Xcode can be upgraded on a self-hosted runner before its matching iOS platform
# component is installed. In that state, xcrun may exist but the generic iOS
# destination is ineligible, and archive fails with exit code 70. Probe the exact
# destination used below and repair the Xcode installation before starting the
# expensive archive.
if ! xcodebuild \
-project "$XCODEPROJ" \
-scheme "$SCHEME" \
-showBuildSettings \
-destination "generic/platform=iOS" >/dev/null; then
echo "The generic iOS destination is unavailable; installing Xcode's iOS platform..."
if ! xcodebuild -checkFirstLaunchStatus; then
echo "Completing pending Xcode first-launch setup..."
xcodebuild -runFirstLaunch
fi
xcodebuild -downloadPlatform iOS
fi
echo "Installed Xcode SDKs:"
xcodebuild -showsdks
IOS_SDK_PATH=$(xcrun --sdk iphoneos --show-sdk-path)
if [ ! -d "$IOS_SDK_PATH" ]; then
echo "Error: Xcode reported an invalid iOS SDK path: $IOS_SDK_PATH"
exit 1
fi
echo "Using iOS SDK: $IOS_SDK_PATH"
if ! xcodebuild \
-project "$XCODEPROJ" \
-scheme "$SCHEME" \
-showBuildSettings \
-destination "generic/platform=iOS" >/dev/null; then
echo "Error: The generic iOS destination is still unavailable after platform setup."
xcodebuild -project "$XCODEPROJ" -scheme "$SCHEME" -showdestinations || true
exit 1
fi
# Archive without signing - we'll sign during export
# This avoids issues with provisioning profiles on framework targets
xcodebuild archive \
-34
View File
@@ -9,23 +9,13 @@ WORKSPACE=$(pwd)
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
BUILD_DIR=$1
LOG_PATH=$2
BUILD_ADDRESSABLES=${3:-true}
ADDRESSABLES_BUILD_LAYOUT=${4:-$BUILD_ADDRESSABLES}
echo "Building Mac in $BUILD_DIR"
echo "Build Addressables: $BUILD_ADDRESSABLES"
echo "Generate Addressables build layout: $ADDRESSABLES_BUILD_LAYOUT"
echo "Cleaning up $BUILD_DIR"
/bin/rm -rf "$BUILD_DIR"
/bin/mkdir -p "$BUILD_DIR"
if [ -n "${DOTNET_ROOT:-}" ]; then
export PATH="$DOTNET_ROOT:$PATH"
export DOTNET_HOST_PATH="$DOTNET_ROOT/dotnet"
fi
dotnet --list-sdks
# Use custom build script that builds Addressables before the player
# Capture exit code to show log on failure
set +e
@@ -35,8 +25,6 @@ ${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
-quit \
-executeMethod BuildScript.BuildMacPlayer \
-buildPath "$BUILD_DIR/eagle0.app" \
-buildAddressables "$BUILD_ADDRESSABLES" \
-addressablesBuildLayout "$ADDRESSABLES_BUILD_LAYOUT" \
-logFile "$LOG_PATH" \
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
UNITY_EXIT_CODE=$?
@@ -50,25 +38,3 @@ if [ $UNITY_EXIT_CODE -ne 0 ]; then
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
+2 -3
View File
@@ -15,7 +15,6 @@ git log -3
/bin/echo "build Windows"
LOG_PATH="${BUILD_BASE}/editor_win.log"
BUILD_DIR=$1
BUILD_ADDRESSABLES=${2:-true}
ADDRESSABLES_BUILD_LAYOUT=${3:-$BUILD_ADDRESSABLES}
./ci/github_actions/build_windows.sh "$BUILD_DIR" "$LOG_PATH" "$BUILD_ADDRESSABLES" "$ADDRESSABLES_BUILD_LAYOUT"
./ci/github_actions/build_windows.sh $BUILD_DIR $LOG_PATH
-36
View File
@@ -12,8 +12,6 @@ BUILD_BASE="${EAGLE0_BUILD_DIR:-/tmp/eagle0}"
WORKSPACE=$(pwd)
BUILD_PATH=${1:-"${BUILD_BASE}/eagle0iOS"}
BUILD_ADDRESSABLES=${2:-true}
ADDRESSABLES_BUILD_LAYOUT=${3:-$BUILD_ADDRESSABLES}
LOG_PATH="${BUILD_BASE}/editor_ios.log"
# Generate unique build number from git commit count
@@ -27,8 +25,6 @@ echo "Building protos"
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
echo "Building iOS Unity player to: $BUILD_PATH"
echo "Build Addressables: $BUILD_ADDRESSABLES"
echo "Generate Addressables build layout: $ADDRESSABLES_BUILD_LAYOUT"
mkdir -p "$(dirname "$LOG_PATH")"
mkdir -p "$BUILD_PATH"
@@ -43,8 +39,6 @@ ${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
-executeMethod BuildScript.BuildiOSPlayer \
-buildPath "$BUILD_PATH" \
-buildNumber "$BUILD_NUMBER" \
-buildAddressables "$BUILD_ADDRESSABLES" \
-addressablesBuildLayout "$ADDRESSABLES_BUILD_LAYOUT" \
-logFile "$LOG_PATH" \
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
UNITY_EXIT_CODE=$?
@@ -59,36 +53,6 @@ if [ $UNITY_EXIT_CODE -ne 0 ]; then
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 "$BUILD_PATH/Data/Raw/aa/settings.json" ]; then
echo ""
echo "ERROR: iOS player is missing Addressables runtime data at:"
echo " $BUILD_PATH/Data/Raw/aa/settings.json"
echo "Build Addressables before packaging the player so Addressables can initialize at runtime."
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
echo "iOS Unity build complete"
echo "Xcode project generated at: $BUILD_PATH"
ls -la "$BUILD_PATH"
+1 -3
View File
@@ -18,7 +18,5 @@ git log -3
/bin/echo "build Mac"
LOG_PATH="${BUILD_BASE}/editor_mac.log"
BUILD_DIR=$1
BUILD_ADDRESSABLES=${2:-true}
ADDRESSABLES_BUILD_LAYOUT=${3:-$BUILD_ADDRESSABLES}
./ci/github_actions/build_mac.sh "$BUILD_DIR" "$LOG_PATH" "$BUILD_ADDRESSABLES" "$ADDRESSABLES_BUILD_LAYOUT"
./ci/github_actions/build_mac.sh "$BUILD_DIR" "$LOG_PATH"
-42
View File
@@ -9,23 +9,13 @@ WORKSPACE=`pwd`
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
BUILD_DIR=$1
LOG_PATH=$2
BUILD_ADDRESSABLES=${3:-true}
ADDRESSABLES_BUILD_LAYOUT=${4:-$BUILD_ADDRESSABLES}
echo "Building in $1"
echo "Build Addressables: $BUILD_ADDRESSABLES"
echo "Generate Addressables build layout: $ADDRESSABLES_BUILD_LAYOUT"
echo "Cleaning up $1"
/bin/rm -rf $1
/bin/mkdir -p $1
if [ -n "${DOTNET_ROOT:-}" ]; then
export PATH="$DOTNET_ROOT:$PATH"
export DOTNET_HOST_PATH="$DOTNET_ROOT/dotnet"
fi
dotnet --list-sdks
# Use custom build script that builds Addressables before the player
# Capture exit code to show log on failure
set +e
@@ -35,8 +25,6 @@ ${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
-quit \
-executeMethod BuildScript.BuildWindowsPlayer \
-buildPath "$BUILD_DIR/eagle0.exe" \
-buildAddressables "$BUILD_ADDRESSABLES" \
-addressablesBuildLayout "$ADDRESSABLES_BUILD_LAYOUT" \
-logFile $LOG_PATH \
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
UNITY_EXIT_CODE=$?
@@ -50,33 +38,3 @@ if [ $UNITY_EXIT_CODE -ne 0 ]; then
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
if [ ! -f "$BUILD_DIR/eagle0_Data/StreamingAssets/aa/settings.json" ]; then
echo ""
echo "ERROR: Windows player is missing Addressables runtime data at:"
echo " $BUILD_DIR/eagle0_Data/StreamingAssets/aa/settings.json"
echo "Build Addressables before packaging the player so Addressables can initialize at runtime."
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,64 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_SHA=${1:-}
HEAD_SHA=${2:-HEAD}
OUTPUT_FILE=${GITHUB_OUTPUT:-}
if [ -z "$BASE_SHA" ] || [[ "$BASE_SHA" =~ ^0+$ ]]; then
echo "No usable base SHA for this run; building Addressables as a safe default."
changed=true
elif ! git cat-file -e "$BASE_SHA^{commit}"; then
echo "Base SHA $BASE_SHA is not available locally; building Addressables as a safe default."
changed=true
else
UNITY_PROJECT="src/main/csharp/net/eagle0/clients/unity/eagle0"
ADDRESSABLE_PREFIXES=(
"$UNITY_PROJECT/Assets/AddressableAssetsData/"
"$UNITY_PROJECT/Assets/Eagle/Effects/"
"$UNITY_PROJECT/Assets/Hex Tiles/"
"$UNITY_PROJECT/Assets/TileableBridgePack/"
"$UNITY_PROJECT/Assets/Shardok/LargeFlames.prefab"
"$UNITY_PROJECT/Assets/Shardok/soundEffects/"
"$UNITY_PROJECT/Assets/Shardok/Sounds/"
"$UNITY_PROJECT/Assets/Medieval Combat Sounds/"
"$UNITY_PROJECT/Assets/Magic Spells Sound Effects LITE/"
"$UNITY_PROJECT/Assets/Fantasy Interface Sounds/"
"$UNITY_PROJECT/Assets/AustraliaAnimalsPackv1/"
"$UNITY_PROJECT/Assets/Music/"
"$UNITY_PROJECT/Assets/Editor/BuildScript.cs"
)
is_addressables_input() {
local file="$1"
for prefix in "${ADDRESSABLE_PREFIXES[@]}"; do
if [[ "$file" == "$prefix"* ]]; then
return 0
fi
done
return 1
}
changed=false
while IFS= read -r file; do
if is_addressables_input "$file"; then
echo "Addressables-impacting change: $file"
changed=true
break
fi
done < <(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
fi
if [ "$changed" = "true" ]; then
echo "Building Addressables because their inputs changed."
else
echo "Skipping Addressables build; no Addressables inputs changed."
fi
if [ -n "$OUTPUT_FILE" ]; then
echo "changed=$changed" >> "$OUTPUT_FILE"
else
echo "changed=$changed"
fi
-102
View File
@@ -1,102 +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}"
repo_contents_cache_dir() {
if [ "$(uname -s)" = "Darwin" ]; then
printf '%s\n' "${HOME}/Library/Caches/eagle0-bazel-repo-contents"
else
printf '%s\n' "${XDG_CACHE_HOME:-${HOME}/.cache}/eagle0-bazel-repo-contents"
fi
}
write_bazel_local_config() {
local repo_contents_cache
repo_contents_cache="$(repo_contents_cache_dir)"
if [ "$(uname -s)" != "Darwin" ]; then
if [ -n "${CI:-}" ]; then
mkdir -p "$repo_contents_cache"
{
echo "# Generated by ci/github_actions/ensure_bazel_installed.sh; do not edit."
printf 'common --repo_contents_cache=%s\n' "$repo_contents_cache"
} > .bazelrc.local
fi
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
mkdir -p "$repo_contents_cache"
{
echo "# Generated by ci/github_actions/ensure_bazel_installed.sh; do not edit."
printf 'common --repo_contents_cache=%s\n' "$repo_contents_cache"
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_bazel_local_config
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
+11 -32
View File
@@ -34,7 +34,6 @@ echo "Platform: ${PLATFORM}"
# Check if Unity is installed and has required modules
check_modules_installed() {
local unity_path="${UNITY_INSTALL_PATH}/${UNITY_VERSION}"
local unity_app_contents_path="${unity_path}/Unity.app/Contents"
if [ ! -d "$unity_path" ]; then
return 1
@@ -49,12 +48,15 @@ check_modules_installed() {
fi
;;
mac)
if ! has_mac_il2cpp_support "$unity_path" "$unity_app_contents_path"; then
# 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
@@ -67,8 +69,8 @@ check_modules_installed() {
echo "✗ iOS module missing"
missing=1
fi
if ! has_mac_il2cpp_support "$unity_path" "$unity_app_contents_path"; then
echo "✗ Mac IL2CPP module missing"
if [ ! -d "${unity_path}/PlaybackEngines/MacStandaloneSupport" ]; then
echo "✗ Mac module missing"
missing=1
fi
if [ ! -d "${unity_path}/PlaybackEngines/WindowsStandaloneSupport" ]; then
@@ -84,27 +86,6 @@ check_modules_installed() {
return 0
}
has_mac_il2cpp_support() {
local unity_path="$1"
local unity_app_contents_path="$2"
has_il2cpp_variation "${unity_app_contents_path}/PlaybackEngines/MacStandaloneSupport/Variations" ||
has_il2cpp_variation "${unity_path}/PlaybackEngines/MacStandaloneSupport/Variations"
}
has_il2cpp_variation() {
local variations_path="$1"
local variation
for variation in "${variations_path}"/*il2cpp*; do
if [ -d "$variation" ]; then
return 0
fi
done
return 1
}
# Check if already installed with required modules
if check_modules_installed; then
echo "✓ Unity ${UNITY_VERSION} is already installed with ${PLATFORM} support"
@@ -210,10 +191,8 @@ echo "Running: ${INSTALL_CMD[*]}"
echo ""
# Capture output to check for "already installed" messages
set +e
OUTPUT=$("${INSTALL_CMD[@]}" 2>&1)
OUTPUT=$("${INSTALL_CMD[@]}" 2>&1) || true
EXIT_CODE=$?
set -e
echo "$OUTPUT"
# Check if modules are already installed (Unity Hub returns error but modules are present)
@@ -240,13 +219,13 @@ else
exit 1
fi
# Verify installation and requested modules
# Verify installation
echo ""
if check_modules_installed; then
echo "✓ Verified: Unity ${UNITY_VERSION} is now installed with ${PLATFORM} support"
if [ -d "${UNITY_INSTALL_PATH}/${UNITY_VERSION}" ]; then
echo "✓ Verified: Unity ${UNITY_VERSION} is now installed"
exit 0
else
echo "✗ Unity ${UNITY_VERSION} installation with ${PLATFORM} support could not be verified"
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."
+2 -49
View File
@@ -11,54 +11,16 @@
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 --local --force
git lfs install
echo "LFS objects before pull:"
git lfs ls-files | wc -l
GIT_LFS_PULL=(git lfs pull)
FALLBACK_GITHUB_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)
FALLBACK_GITHUB_LFS_PULL=(
git
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic $BASIC_AUTH"
-c "lfs.url=https://github.com/nolen777/eagle0.git/info/lfs"
lfs
pull
)
fi
for i in $(seq 1 $MAX_ATTEMPTS); do
if "${GIT_LFS_PULL[@]}" "$@"; then
if git lfs pull "$@"; then
echo "LFS objects after pull:"
git lfs ls-files | wc -l
exit 0
@@ -69,14 +31,5 @@ for i in $(seq 1 $MAX_ATTEMPTS); do
fi
done
if [ "${#FALLBACK_GITHUB_LFS_PULL[@]}" -gt 0 ]; then
echo "LFS mirror pull failed after $MAX_ATTEMPTS attempts; trying GitHub LFS fallback"
if "${FALLBACK_GITHUB_LFS_PULL[@]}" "$@"; then
echo "LFS objects after GitHub fallback pull:"
git lfs ls-files | wc -l
exit 0
fi
fi
echo "LFS pull failed after $MAX_ATTEMPTS attempts"
exit 1
-235
View File
@@ -1,235 +0,0 @@
#!/usr/bin/env python3
"""Print a compact timing summary from a Bazel build event JSON file."""
from __future__ import annotations
import json
import sys
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any
def duration_to_ms(value: Any) -> float | None:
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
if value.endswith("s"):
try:
return float(value[:-1]) * 1000
except ValueError:
return None
try:
return float(value)
except ValueError:
return None
if isinstance(value, dict):
seconds = value.get("seconds", 0)
nanos = value.get("nanos", 0)
try:
return float(seconds) * 1000 + float(nanos) / 1_000_000
except (TypeError, ValueError):
return None
return None
def format_ms(milliseconds: float | None) -> str | None:
if milliseconds is None:
return None
if milliseconds >= 1000:
return f"{milliseconds / 1000:.2f}s"
return f"{milliseconds:.0f}ms"
def nested_get(data: dict[str, Any], *keys: str) -> Any:
current: Any = data
for key in keys:
if not isinstance(current, dict):
return None
current = current.get(key)
return current
def read_events(path: Path) -> list[dict[str, Any]]:
events = []
with path.open() as file:
for line_number, line in enumerate(file, start=1):
stripped = line.strip()
if not stripped:
continue
try:
event = json.loads(stripped)
except json.JSONDecodeError as error:
print(f"Ignoring malformed BEP line {line_number}: {error}")
continue
if isinstance(event, dict):
events.append(event)
return events
def collect_metric_events(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
metric_events = []
for event in events:
if isinstance(event.get("buildMetrics"), dict):
metric_events.append(event["buildMetrics"])
return metric_events
def first_present(data: dict[str, Any], keys: list[str]) -> Any:
for key in keys:
value = data.get(key)
if value is not None:
return value
return None
def summarize_timing(build_metrics: dict[str, Any]) -> list[str]:
timing = build_metrics.get("timingMetrics")
if not isinstance(timing, dict):
return []
labels = [
("Wall time", ["wallTimeInMs", "wallTime", "elapsedTime"]),
("Loading phase", ["loadingPhaseTimeInMs", "loadingPhaseTime"]),
("Analysis phase", ["analysisPhaseTimeInMs", "analysisPhaseTime"]),
("Execution phase", ["executionPhaseTimeInMs", "executionPhaseTime"]),
("Critical path", ["criticalPathTimeInMs", "criticalPathTime"]),
]
lines = []
for label, keys in labels:
value = format_ms(duration_to_ms(first_present(timing, keys)))
if value is not None:
lines.append(f" {label}: {value}")
return lines
def summarize_counts(build_metrics: dict[str, Any]) -> list[str]:
summaries: list[tuple[str, Any]] = []
action_summary = build_metrics.get("actionSummary")
if isinstance(action_summary, dict):
summaries.extend(
[
("Actions created", action_summary.get("actionsCreated")),
("Actions executed", action_summary.get("actionsExecuted")),
("Action cache hits", action_summary.get("actionCacheHits")),
("Remote cache hits", action_summary.get("remoteCacheHits")),
]
)
target_metrics = build_metrics.get("targetMetrics")
if isinstance(target_metrics, dict):
summaries.extend(
[
("Targets configured", target_metrics.get("targetsConfigured")),
("Targets loaded", target_metrics.get("targetsLoaded")),
]
)
package_metrics = build_metrics.get("packageMetrics")
if isinstance(package_metrics, dict):
summaries.extend(
[
("Packages loaded", package_metrics.get("packagesLoaded")),
("Packages successfully loaded", package_metrics.get("packagesSuccessfullyLoaded")),
]
)
return [f" {label}: {value}" for label, value in summaries if value is not None]
def worker_mnemonic(metric: dict[str, Any]) -> str:
for path in [
("mnemonic",),
("workerKey", "mnemonic"),
("workerKeyInfo", "mnemonic"),
]:
value = nested_get(metric, *path)
if value:
return str(value)
return "unknown"
def worker_actions(metric: dict[str, Any]) -> int | None:
for key in ["actionsExecuted", "executedActionCount", "actionCount", "actions"]:
value = metric.get(key)
if isinstance(value, int):
return value
if isinstance(value, str):
try:
return int(value)
except ValueError:
pass
return None
def summarize_workers(build_metrics: dict[str, Any]) -> list[str]:
worker_metrics = build_metrics.get("workerMetrics")
if not isinstance(worker_metrics, list) or not worker_metrics:
return []
counts = Counter()
actions_by_mnemonic: defaultdict[str, int] = defaultdict(int)
saw_action_counts = False
for metric in worker_metrics:
if not isinstance(metric, dict):
continue
mnemonic = worker_mnemonic(metric)
counts[mnemonic] += 1
actions = worker_actions(metric)
if actions is not None:
saw_action_counts = True
actions_by_mnemonic[mnemonic] += actions
if not counts:
return []
lines = [" Worker metric entries:"]
for mnemonic, count in counts.most_common():
suffix = ""
if saw_action_counts:
suffix = f", actions={actions_by_mnemonic[mnemonic]}"
lines.append(f" {mnemonic}: workers={count}{suffix}")
return lines
def main() -> int:
if len(sys.argv) != 2:
print("Usage: summarize_bazel_bep.py <build_event_json_file>")
return 0
path = Path(sys.argv[1])
if not path.exists():
print(f"Bazel BEP summary: {path} does not exist")
return 0
events = read_events(path)
metric_events = collect_metric_events(events)
if not metric_events:
print("Bazel BEP summary: no buildMetrics event found")
return 0
build_metrics = metric_events[-1]
sections = [
summarize_timing(build_metrics),
summarize_counts(build_metrics),
summarize_workers(build_metrics),
]
lines = [line for section in sections for line in section]
if not lines:
print("Bazel BEP summary: buildMetrics event had no recognized metrics")
return 0
print("Bazel BEP summary:")
for line in lines:
print(line)
return 0
if __name__ == "__main__":
sys.exit(main())
-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"
@@ -1,137 +0,0 @@
#!/usr/bin/env python3
"""Update pinned LLVM release asset names and SHA-256 digests in MODULE.bazel."""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import urllib.request
from pathlib import Path
from typing import Any
LLVM_VERSION_PATTERN = re.compile(r'^LLVM_VERSION = "(?P<version>\d+\.\d+\.\d+)"$', re.MULTILINE)
LLVM_DISTRIBUTIONS_PATTERN = re.compile(
r"^LLVM_DISTRIBUTIONS = \{\n.*?^\}$",
re.MULTILINE | re.DOTALL,
)
LLVM_DISTRIBUTION_PLATFORMS = (
"Linux-ARM64",
"Linux-X64",
"macOS-ARM64",
)
def fetch_release(version: str, token: str | None) -> dict[str, Any]:
url = f"https://api.github.com/repos/llvm/llvm-project/releases/tags/llvmorg-{version}"
headers = {
"Accept": "application/vnd.github+json",
"User-Agent": "eagle0-llvm-distribution-updater",
"X-GitHub-Api-Version": "2022-11-28",
}
if token:
headers["Authorization"] = f"Bearer {token}"
request = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
def load_release(path: Path) -> dict[str, Any]:
with path.open(encoding="utf-8") as release_file:
return json.load(release_file)
def llvm_distributions(version: str, release: dict[str, Any]) -> dict[str, str]:
expected_tag = f"llvmorg-{version}"
if release.get("tag_name") != expected_tag:
raise ValueError(
f"Expected release tag {expected_tag}, got {release.get('tag_name')!r}"
)
assets = {asset["name"]: asset for asset in release.get("assets", [])}
distributions: dict[str, str] = {}
for platform in LLVM_DISTRIBUTION_PLATFORMS:
name = f"LLVM-{version}-{platform}.tar.xz"
asset = assets.get(name)
if asset is None:
raise ValueError(f"LLVM release {version} is missing required asset {name}")
digest = asset.get("digest")
if not isinstance(digest, str) or not digest.startswith("sha256:"):
raise ValueError(f"LLVM release asset {name} has no SHA-256 digest")
distributions[name] = digest.removeprefix("sha256:")
return distributions
def render_distributions(distributions: dict[str, str]) -> str:
lines = ["LLVM_DISTRIBUTIONS = {"]
lines.extend(f' "{name}": "{digest}",' for name, digest in distributions.items())
lines.append("}")
return "\n".join(lines)
def update_module(module_path: Path, release: dict[str, Any]) -> bool:
original = module_path.read_text(encoding="utf-8")
version_match = LLVM_VERSION_PATTERN.search(original)
if version_match is None:
raise ValueError(f"Could not find LLVM_VERSION in {module_path}")
distributions = llvm_distributions(version_match.group("version"), release)
replacement = render_distributions(distributions)
updated, replacements = LLVM_DISTRIBUTIONS_PATTERN.subn(replacement, original, count=1)
if replacements != 1:
raise ValueError(f"Could not find exactly one LLVM_DISTRIBUTIONS block in {module_path}")
if updated == original:
return False
module_path.write_text(updated, encoding="utf-8")
return True
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--module", type=Path, default=Path("MODULE.bazel"))
parser.add_argument(
"--release-json",
type=Path,
help="Read GitHub release JSON from a file instead of the GitHub API",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
module_text = args.module.read_text(encoding="utf-8")
version_match = LLVM_VERSION_PATTERN.search(module_text)
if version_match is None:
print(f"ERROR: Could not find LLVM_VERSION in {args.module}", file=sys.stderr)
return 1
version = version_match.group("version")
try:
release = (
load_release(args.release_json)
if args.release_json
else fetch_release(version, os.environ.get("GITHUB_TOKEN"))
)
changed = update_module(args.module, release)
except (OSError, ValueError) as error:
print(f"ERROR: {error}", file=sys.stderr)
return 1
if changed:
print(f"Updated LLVM {version} distribution metadata in {args.module}")
else:
print(f"LLVM {version} distribution metadata is current")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,54 +0,0 @@
#!/usr/bin/env python3
import tempfile
import unittest
from pathlib import Path
import update_llvm_distributions
class UpdateLlvmDistributionsTest(unittest.TestCase):
def release(self, *, missing_digest: bool = False) -> dict:
assets = []
for index, platform in enumerate(
update_llvm_distributions.LLVM_DISTRIBUTION_PLATFORMS, start=1
):
asset = {
"name": f"LLVM-22.1.8-{platform}.tar.xz",
"digest": f"sha256:{index:064x}",
}
assets.append(asset)
if missing_digest:
assets[0]["digest"] = None
return {"tag_name": "llvmorg-22.1.8", "assets": assets}
def test_updates_asset_versions_and_digests(self) -> None:
with tempfile.TemporaryDirectory() as directory:
module = Path(directory) / "MODULE.bazel"
module.write_text(
'LLVM_VERSION = "22.1.8"\n\n'
"LLVM_DISTRIBUTIONS = {\n"
' "LLVM-22.1.7-Linux-ARM64.tar.xz": "old-arm",\n'
' "LLVM-22.1.7-Linux-X64.tar.xz": "old-x64",\n'
' "LLVM-22.1.7-macOS-ARM64.tar.xz": "old-mac",\n'
"}\n",
encoding="utf-8",
)
changed = update_llvm_distributions.update_module(module, self.release())
self.assertTrue(changed)
updated = module.read_text(encoding="utf-8")
self.assertNotIn("22.1.7", updated)
self.assertIn('"LLVM-22.1.8-Linux-ARM64.tar.xz": "' + "1".zfill(64), updated)
self.assertIn('"LLVM-22.1.8-macOS-ARM64.tar.xz": "' + "3".zfill(64), updated)
def test_rejects_asset_without_sha256_digest(self) -> None:
with self.assertRaisesRegex(ValueError, "has no SHA-256 digest"):
update_llvm_distributions.llvm_distributions(
"22.1.8", self.release(missing_digest=True)
)
if __name__ == "__main__":
unittest.main()
+7 -177
View File
@@ -13,166 +13,11 @@ set -euxo pipefail
BUILD_TARGET=$1
WORKSPACE=$(pwd)
UNITY_PROJECT="$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
UNITY_VERSION=$(grep "m_EditorVersion:" "$UNITY_PROJECT/ProjectSettings/ProjectVersion.txt" | head -1 | sed 's/m_EditorVersion: //')
UNITY_MAJOR_MINOR=$(echo "$UNITY_VERSION" | sed -E 's/^([0-9]+)\.([0-9]+).*/\1.\2/')
ADDRESSABLES_PREFIX="addressables/$BUILD_TARGET/$UNITY_MAJOR_MINOR"
SERVER_DATA="$UNITY_PROJECT/ServerData/$BUILD_TARGET/$UNITY_MAJOR_MINOR"
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"
DEFAULT_MAX_UPLOAD_BYTES=$((1024 * 1024 * 1024))
DEFAULT_MAX_SINGLE_FILE_BYTES=$((512 * 1024 * 1024))
MAX_UPLOAD_BYTES=${ADDRESSABLES_MAX_UPLOAD_BYTES:-$DEFAULT_MAX_UPLOAD_BYTES}
MAX_SINGLE_FILE_BYTES=${ADDRESSABLES_MAX_SINGLE_FILE_BYTES:-$DEFAULT_MAX_SINGLE_FILE_BYTES}
TOP_UPLOAD_COUNT=${ADDRESSABLES_TOP_UPLOAD_COUNT:-20}
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export PATH="${COMMON_PATHS}:${PATH}"
format_bytes() {
local bytes=$1
local unit="B"
local value=$bytes
if [ "$bytes" -ge $((1024 * 1024 * 1024)) ]; then
unit="GiB"
value=$(awk "BEGIN { printf \"%.2f\", $bytes / 1024 / 1024 / 1024 }")
elif [ "$bytes" -ge $((1024 * 1024)) ]; then
unit="MiB"
value=$(awk "BEGIN { printf \"%.2f\", $bytes / 1024 / 1024 }")
elif [ "$bytes" -ge 1024 ]; then
unit="KiB"
value=$(awk "BEGIN { printf \"%.2f\", $bytes / 1024 }")
fi
echo "$value $unit"
}
file_size() {
local path=$1
if stat -f%z "$path" >/dev/null 2>&1; then
stat -f%z "$path"
else
stat -c%s "$path"
fi
}
append_summary() {
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
echo "$1" >> "$GITHUB_STEP_SUMMARY"
fi
}
budget_enabled() {
local value=$1
[ "$value" -gt 0 ]
}
summarize_pending_uploads() {
local sync_args_file
sync_args_file=$(mktemp)
local uploads_file
uploads_file=$(mktemp)
local sorted_uploads_file
sorted_uploads_file=$(mktemp)
trap 'rm -f "$sync_args_file" "$uploads_file" "$sorted_uploads_file"' RETURN
aws s3 sync "$SERVER_DATA" "s3://$DO_BUCKET/$ADDRESSABLES_PREFIX/" \
--endpoint-url "$DO_ENDPOINT" \
--acl public-read \
--size-only \
--delete \
--dryrun > "$sync_args_file"
while IFS= read -r line; do
case "$line" in
*upload:*)
local source_path=${line#*upload: }
source_path=${source_path% to s3://*}
if [ -f "$source_path" ]; then
local size
size=$(file_size "$source_path")
printf "%s\t%s\n" "$size" "$source_path" >> "$uploads_file"
fi
;;
esac
done < "$sync_args_file"
local upload_count=0
local upload_bytes=0
if [ -s "$uploads_file" ]; then
while IFS=$'\t' read -r size _path; do
upload_count=$((upload_count + 1))
upload_bytes=$((upload_bytes + size))
done < "$uploads_file"
fi
echo "Addressables upload preflight: $upload_count changed files, $(format_bytes "$upload_bytes") to upload."
append_summary "## Addressables upload preflight"
append_summary ""
append_summary "- Target: \`$BUILD_TARGET\`"
append_summary "- Changed files: \`$upload_count\`"
append_summary "- Changed upload bytes: \`$(format_bytes "$upload_bytes")\`"
append_summary "- Upload byte budget: \`$(format_bytes "$MAX_UPLOAD_BYTES")\`"
append_summary "- Single-file budget: \`$(format_bytes "$MAX_SINGLE_FILE_BYTES")\`"
local guardrail_failed=0
if [ -s "$uploads_file" ]; then
echo "Largest pending Addressables uploads:"
append_summary ""
append_summary "### Largest pending uploads"
append_summary ""
append_summary "| Size | Path |"
append_summary "| ---: | --- |"
sort -nr "$uploads_file" > "$sorted_uploads_file"
local listed_uploads=0
while IFS=$'\t' read -r size path; do
if [ "$listed_uploads" -ge "$TOP_UPLOAD_COUNT" ]; then
break
fi
local relative_path=${path#"$SERVER_DATA"/}
echo " $(format_bytes "$size") $relative_path"
append_summary "| $(format_bytes "$size") | \`$relative_path\` |"
listed_uploads=$((listed_uploads + 1))
done < "$sorted_uploads_file"
if budget_enabled "$MAX_SINGLE_FILE_BYTES"; then
while IFS=$'\t' read -r size path; do
if [ "$size" -gt "$MAX_SINGLE_FILE_BYTES" ]; then
local relative_path=${path#"$SERVER_DATA"/}
echo "ERROR: Pending Addressables upload '$relative_path' is $(format_bytes "$size"), exceeding single-file budget $(format_bytes "$MAX_SINGLE_FILE_BYTES")."
append_summary ""
append_summary ":x: \`$relative_path\` exceeds the single-file budget at \`$(format_bytes "$size")\`."
guardrail_failed=1
fi
done < "$uploads_file"
fi
fi
if budget_enabled "$MAX_UPLOAD_BYTES" && [ "$upload_bytes" -gt "$MAX_UPLOAD_BYTES" ]; then
echo "ERROR: Addressables upload is expected to send $(format_bytes "$upload_bytes"), exceeding budget $(format_bytes "$MAX_UPLOAD_BYTES")."
echo "A small asset change may have invalidated an oversized bundle; inspect the largest pending uploads above."
append_summary ""
append_summary ":x: Changed upload bytes exceed budget: \`$(format_bytes "$upload_bytes")\` > \`$(format_bytes "$MAX_UPLOAD_BYTES")\`."
guardrail_failed=1
fi
if [ "$guardrail_failed" -ne 0 ]; then
echo "Refusing to upload Addressables. Raise ADDRESSABLES_MAX_UPLOAD_BYTES or ADDRESSABLES_MAX_SINGLE_FILE_BYTES only after confirming the upload is intentional."
exit 1
fi
}
if ! command -v aws >/dev/null 2>&1; then
if command -v brew >/dev/null 2>&1; then
brew install awscli
else
echo "ERROR: aws CLI is unavailable, and Homebrew is not on PATH"
exit 1
fi
fi
if [ ! -d "$SERVER_DATA" ]; then
echo "No Addressables bundles found at $SERVER_DATA"
@@ -181,34 +26,19 @@ if [ ! -d "$SERVER_DATA" ]; then
fi
echo "Uploading Addressables bundles from $SERVER_DATA"
echo "Target: s3://$DO_BUCKET/$ADDRESSABLES_PREFIX/"
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"
summarize_pending_uploads
# Sync bundles to Spaces. Addressable bundle filenames include content hashes, so a
# same-sized existing bundle is already the same content. Use --size-only to avoid
# re-uploading every rebuilt bundle just because Unity gave it a fresh local mtime.
# Metadata files keep stable names, so sync them again without --size-only below.
#
# --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_PREFIX/" \
# 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 \
--size-only \
--delete
aws s3 sync "$SERVER_DATA" "s3://$DO_BUCKET/$ADDRESSABLES_PREFIX/" \
--endpoint-url "$DO_ENDPOINT" \
--acl public-read \
--exclude "*" \
--include "*.bin" \
--include "*.hash" \
--include "*.json"
echo "Addressables upload complete"
echo "Files available at: https://assets.eagle0.net/$ADDRESSABLES_PREFIX/"
echo "Files available at: https://assets.eagle0.net/addressables/$BUILD_TARGET/"
-59
View File
@@ -1,59 +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.
"""
load("@rules_java//java/common:java_info.bzl", "JavaInfo")
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.",
),
},
)
+2
View File
@@ -1,3 +1,5 @@
load("@bazel_tools//tools/build_defs/pkg:pkg.bzl", "pkg_tar")
#pkg_tar(
# name = "eagle_servers",
# strip_prefix = "/src/main/scala/net/eagle0",
-28
View File
@@ -32,20 +32,6 @@ services:
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}"
EAGLE_POSTGRES_MAX_POOL_SIZE: "${EAGLE_POSTGRES_MAX_POOL_SIZE:-7}"
EAGLE_POSTGRES_MIN_IDLE: "${EAGLE_POSTGRES_MIN_IDLE:-0}"
EAGLE_POSTGRES_CONNECTION_TIMEOUT_MILLIS: "${EAGLE_POSTGRES_CONNECTION_TIMEOUT_MILLIS:-10000}"
EAGLE_POSTGRES_IDLE_TIMEOUT_MILLIS: "${EAGLE_POSTGRES_IDLE_TIMEOUT_MILLIS:-120000}"
EAGLE_POSTGRES_MAX_LIFETIME_MILLIS: "${EAGLE_POSTGRES_MAX_LIFETIME_MILLIS:-1500000}"
EAGLE_POSTGRES_KEEPALIVE_TIME_MILLIS: "${EAGLE_POSTGRES_KEEPALIVE_TIME_MILLIS:-300000}"
EAGLE_POSTGRES_LEAK_DETECTION_THRESHOLD_MILLIS: "${EAGLE_POSTGRES_LEAK_DETECTION_THRESHOLD_MILLIS:-60000}"
# JWT public key for token validation (auth service handles signing)
# Reads from /etc/eagle0/keys/public.pem via shared volume
# Auth token for Shardok on Hetzner (required)
@@ -95,20 +81,6 @@ services:
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}"
EAGLE_POSTGRES_MAX_POOL_SIZE: "${EAGLE_POSTGRES_MAX_POOL_SIZE:-7}"
EAGLE_POSTGRES_MIN_IDLE: "${EAGLE_POSTGRES_MIN_IDLE:-0}"
EAGLE_POSTGRES_CONNECTION_TIMEOUT_MILLIS: "${EAGLE_POSTGRES_CONNECTION_TIMEOUT_MILLIS:-10000}"
EAGLE_POSTGRES_IDLE_TIMEOUT_MILLIS: "${EAGLE_POSTGRES_IDLE_TIMEOUT_MILLIS:-120000}"
EAGLE_POSTGRES_MAX_LIFETIME_MILLIS: "${EAGLE_POSTGRES_MAX_LIFETIME_MILLIS:-1500000}"
EAGLE_POSTGRES_KEEPALIVE_TIME_MILLIS: "${EAGLE_POSTGRES_KEEPALIVE_TIME_MILLIS:-300000}"
EAGLE_POSTGRES_LEAK_DETECTION_THRESHOLD_MILLIS: "${EAGLE_POSTGRES_LEAK_DETECTION_THRESHOLD_MILLIS:-60000}"
# JWT public key for token validation (auth service handles signing)
# Reads from /etc/eagle0/keys/public.pem via shared volume
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
+1 -1
View File
@@ -68,7 +68,7 @@ private GameObject GetEffectPrefab(string beastName) {
### 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.
In `Gameplay.unity`, find the `ProvinceBeastsController` component and assign your new prefab to the new field.
### 5. Test
+159
View File
@@ -0,0 +1,159 @@
# Hetzner Setup Guide
This guide walks through setting up Hetzner Cloud infrastructure for running Shardok on-demand compute.
## Prerequisites
- All code PRs merged (#4990, #4996, #4998, #5001, #5009)
- Access to DigitalOcean Container Registry (for pulling Shardok ARM64 image)
---
## Step 1: Create Hetzner Cloud Account
1. Go to https://console.hetzner.cloud/
2. Sign up and add payment method
3. Create a new project (e.g., "eagle0")
---
## Step 2: Generate Hetzner API Token
1. In Hetzner Console → Security → API Tokens
2. Click "Generate API Token"
3. Give it **Read & Write** permissions
4. Copy the token (you'll only see it once)
---
## Step 3: Generate Shardok Auth Token
Generate a 256-bit random token for Eagle-Shardok authentication:
```bash
openssl rand -hex 32
```
Save this output - it's the shared secret between Eagle and Shardok.
---
## Step 4: Store Secrets in GitHub Actions
Add these secrets in GitHub → Settings → Secrets and variables → Actions:
| Secret Name | Description |
|-------------|-------------|
| `HETZNER_API_TOKEN` | From Step 2 - for Hetzner API calls |
| `SHARDOK_AUTH_TOKEN` | From Step 3 - shared secret for gRPC auth |
Note: `DO_REGISTRY_TOKEN` already exists and will be used for Hetzner to pull container images.
These secrets will be passed to Eagle at runtime via `docker_build.yml`, similar to how `OPENAI_API_KEY` and other secrets are handled.
---
## Step 5: DNS Setup (for Let's Encrypt)
You need a domain pointing to the Shardok instance for TLS certificates.
### Option A: Floating IP (Recommended)
1. In Hetzner Console → Networking → Floating IPs
2. Create a **Floating IPv6** in **Hillsboro, Oregon (hil)** region
- IPv6 costs €1/month vs €3/month for IPv4
- Hillsboro has better latency to DigitalOcean SFO than Ashburn
- Server-to-server communication works fine with IPv6-only
3. Point `shardok.prod.eagle0.net` to this IP via AAAA record
4. The ShardokInstanceManager will attach this IP to instances on spin-up
**Location choice**: Hillsboro, OR (`hil`) is recommended for US West Coast. Same pricing as Ashburn (`ash`).
### Option B: Dynamic DNS
Update DNS programmatically when instance spins up. More complex but avoids floating IP cost.
---
## Step 6: Upload SSH Key to Hetzner
For debugging access to instances:
1. In Hetzner Console → Security → SSH Keys
2. Click "Add SSH Key"
3. Paste your public key (e.g., `~/.ssh/id_rsa.pub`)
4. Give it a name (e.g., "eagle-deploy")
---
## Step 7: Wire Security Config into Eagle
Update Eagle's startup code to use the security config when connecting to remote Shardok:
```scala
val securityConfig = ShardokSecurityConfig(
useTls = true,
authToken = Some(sys.env("SHARDOK_AUTH_TOKEN"))
)
val channel = ServerSetupHelpers.newChannel(
"shardok.prod.eagle0.net",
50051,
securityConfig
)
```
---
## Testing
### Manual Instance Spin-up
Test the Hetzner integration by triggering instance creation:
```scala
val manager = new ShardokInstanceManager(
hetznerApiToken = sys.env("HETZNER_API_TOKEN"),
// ... other config
)
manager.ensureInstanceRunning()
```
### Verify TLS and Auth
1. Instance spins up and gets Let's Encrypt certificate
2. Eagle connects via TLS
3. Auth token is validated on each request
---
## Cost Estimate
| Component | Cost |
|-----------|------|
| CAX41 (16 ARM cores) | ~$0.04/hour |
| Floating IP | ~$4/month |
| Typical usage (20 hrs/week) | ~$3.50/month compute |
**Total: ~$7-8/month** for typical usage.
---
## Troubleshooting
### Instance won't start
- Check Hetzner API token has Read & Write permissions
- Verify you're using the correct region (`hil` for Hillsboro OR, or `ash` for Ashburn VA)
### TLS certificate fails
- Ensure DNS points to the instance IP before certbot runs
- Check port 80 is open for Let's Encrypt HTTP-01 challenge
### Auth failures
- Verify `SHARDOK_AUTH_TOKEN` matches on both Eagle and Shardok
- Check the token file is readable by Shardok container
### Can't pull container image
- Ensure `DO_REGISTRY_TOKEN` is passed to cloud-init
- Verify the ARM64 image exists: `registry.digitalocean.com/eagle0/shardok-server:arm64-latest`
+6 -7
View File
@@ -42,17 +42,16 @@ Time-to-first-token (TTFT) was measured from request initiation to the first tex
### 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
**Gemini 2.5 Flash-Lite** is recommended as the default:
- Fastest TTFT (~0.6s) - nearly 3x faster than alternatives
- Cheapest pricing ($0.10/$0.40 per 1M tokens)
- Quality is acceptable for short narrative snippets
### Alternative Options
| Priority | Model | When to Use |
|----------|-------|-------------|
| Speed + Cost | Gemini 3.1 Flash-Lite | Default for most use cases |
| Speed + Cost | Gemini 2.5 Flash-Lite | Default for most use cases |
| Speed + Quality | gpt-4.1-mini | When you need OpenAI quality with good speed |
| Instruction Following | claude-3-5-haiku | Complex multi-step prompts, consistent tone |
| Maximum Quality | claude-sonnet-4 or gpt-5.2 | When output quality is paramount |
@@ -71,7 +70,7 @@ 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)
- `GeminiModelName` (default: gemini-2.5-flash-lite)
- `OpenAiModelName` (default: gpt-4.1-mini)
- `ClaudeModelName` (default: claude-3-5-haiku-20241022)
+31 -280
View File
@@ -1,121 +1,23 @@
# New Profession Proposals
This document proposes new hero professions for Eagle0. Each profession should matter in both the Eagle strategic layer
and the Shardok tactical layer, and should be earned through a prime stat in the same way existing professions are.
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
Professions are currently gained when a no-profession hero crosses the profession stat threshold and wins a profession
roll. The current stat mapping is:
| Prime Stat | Professions |
|------------|-------------|
| **Strength** | Champion |
| **Agility** | Engineer, Ranger |
| **Wisdom** | Mage |
| **Charisma** | Necromancer, Paladin |
| **Constitution** | None |
The current profession capabilities are:
| Profession | Eagle Ability | Shardok Abilities | Current Niche |
|------------|---------------|-------------------|---------------|
| **Mage** | Control Weather: start/end blizzards and droughts in the current or neighboring province | Lightning Bolt, Meteor, Freeze Water, Start Fire access/enhancement | Strategic weather control and high-impact elemental battlefield effects |
| **Necromancer** | Start Epidemic in the current or neighboring province | Raise Dead, Fear | Attrition pressure, undead creation, morale attack |
| **Engineer** | Improve command recommendation and bonus improvement output | Repair, Fortify, Build Bridge, Reduce siege | Infrastructure, battlefield construction, mechanical support |
| **Paladin** | Alms priority and increased support from food | Holy Wave | Public support, holy area effect, anti-undead flavor |
| **Ranger** | Recon against enemy provinces | Scout, Hide access/enhancement, Brave Water enhancement | Information, stealth, terrain crossing |
| **Champion** | Train command recommendation and training bonus | Challenge Duel | Martial excellence, battalion training, direct hero confrontation |
## Recommendation
Add **Warden** first.
Warden is the cleanest next profession because Constitution is the only hero stat that does not currently unlock any
profession. A Constitution profession also creates a distinctive tactical identity: not another caster, scout, or damage
dealer, but a durable hero who keeps important units alive. That fills a real mechanical gap without forcing a large
rewrite of the profession system.
The first version should be intentionally simple:
- **Prime stat:** Constitution
- **Eagle ability:** Garrison, a defensive province action or passive defense bonus when a Warden is present with enough
vigor; or Custody, a prisoner-control ability for captured heroes
- **Shardok ability:** Guard, a command that marks an adjacent friendly unit until the Warden's next turn; the first
attack against that unit is redirected to the Warden's unit or reduced by a fixed amount
- **Battalion suitability:** Optimal with durable melee infantry; suboptimal with fragile archers or stealth-focused
units if we want a stricter identity
- **Balance direction:** Spend action points and/or vigor to protect another unit, rather than adding free passive
prevention every round
This keeps the profession legible: high-Constitution heroes become the people who can hold formation, anchor a province,
and protect fragile specialists.
| 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. WARDEN (Defense & Protection Specialist)
**Fantasy:** The stalwart defender who holds the line and protects allies.
**Why it fits now:**
- Gives Constitution a profession path
- Adds a defensive/tank identity that no current profession owns
- Creates counterplay to burst damage, duel pressure, and fragile high-value units
**Eagle Ability: "Garrison"**
- A province with a Warden-led unit gets a defense bonus when attacked
- Alternative active version: spend vigor to fortify the province until next round
- Synergy: works well in border provinces, choke points, and provinces recovering after losses
**Alternative Eagle Ability: "Custody"**
- A Warden can handle captured heroes with extra authority: execute them immediately or move them to a neighboring ruled
province before normal prisoner management
- Passive custody version: each new round, prisoners in a Warden-guarded province become more likely to join the ruling
faction
- Prefer implementing this as a small positive addition to the prisoner's existing `factionBiases` entry for the ruling
faction, rather than changing the prisoner `roundsInType` multiplier from `+5/round` to `+10/round`
- Rationale: changing the multiplier would be retroactive. A prisoner held for ten rounds would immediately receive a
large odds jump the moment a Warden arrived. Accumulating a Warden-specific bias only rewards rounds actually spent
under Warden custody, and the existing faction-bias decay makes the effect fade naturally if the Warden leaves
- Stronger version: if the Warden's side captures enemy heroes during battle but still loses the province, the Warden can
evacuate one captured hero to a neighboring ruled province instead of losing custody
- This should be limited to one captured hero per battle or require a high-vigor Warden, because keeping prisoners after
losing the battle is a major strategic swing
- Best flavor: Wardens are not just defenders of walls, but keepers of oaths, chains, and battlefield custody
**Shardok Ability: "Guard"**
- Target adjacent friendly unit is protected until the Warden's next turn
- First incoming attack against the protected unit is redirected to the Warden's unit or reduced
- Costs action points and cannot target self
- Creates a clear positioning puzzle without introducing another long-range damage button
**Implementation Notes:**
- Add `Warden` to the Scala profession enum and common/client profession enums
- Map Constitution to `Vector(Profession.Warden)` in `HeroStatGainAction`
- Add display names, profession-gained notification copy, tutorial trigger mapping, and headshot bucket support
- Add strategic availability and command handling only after deciding whether Warden uses Garrison, Custody, or both
- For Custody, prefer extending the existing captured-hero/prisoner-management flow instead of creating a parallel
prisoner system
- For passive prisoner conversion, update `NewRoundAction` near the existing unaffiliated-hero `roundsInType` and
`factionBiases` maintenance. If the province is ruled, contains a ruling-faction Warden, and the unaffiliated hero is
a prisoner, add the Warden custody bonus to that prisoner's `factionBiases(rulingFactionId)`.
- For Shardok, start with a single-turn guard status before attempting more complex interception chains
**Current Follow-Up Priorities:**
- Teach the Shardok AI when to use the Warden evacuation command. The command is valuable when the Warden's side has
captured enemy heroes and can secure them by leaving the battle, especially when the battle outcome is uncertain or
trending against that side.
- Add the strategic passive custody bonus: each round, prisoners in a province guarded by a ruling-faction Warden should
get a small accumulated positive bias toward joining that faction. Prefer storing this as an addition to the existing
`factionBiases` entry rather than changing the global `roundsInType` multiplier, so the bonus only reflects rounds
actually spent under Warden custody.
- Make tactical AI more protective of heroes at risk of capture when a Warden may be present. Warden evacuation makes
captured heroes harder to recover, so the AI should treat exposing high-value heroes to capture as more dangerous than
before. This matters even when the AI does not have perfect knowledge that a Warden is in the battle.
### 2. HERALD (Morale & Communication Specialist)
### 1. HERALD (Morale & Communication Specialist)
**Fantasy:** The inspiring leader who rallies troops and carries messages across the battlefield.
@@ -130,7 +32,7 @@ and protect fragile specialists.
---
### 3. ALCHEMIST (Fire & Transformation Specialist)
### 2. ALCHEMIST (Fire & Transformation Specialist)
**Fantasy:** The mad scientist who manipulates the elements through science, not magic.
@@ -145,6 +47,21 @@ and protect fragile specialists.
---
### 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.
@@ -179,174 +96,8 @@ and protect fragile specialists.
These professions were designed to:
1. **Fill mechanical gaps**: Warden gives Constitution a profession and adds defensive depth
2. **Create counterplay**: Inquisitor vs Mage/Necromancer, Warden vs burst damage, Beastmaster vs beast events
3. **Avoid overlap**: Each profession should own a tactical and strategic niche not covered by existing professions
4. **Support both layers**: Each ability should be meaningful in Eagle and Shardok
5. **Enable interesting decisions**: Guard creates positioning choices, Inspire creates action economy choices, Transmute
creates resource tradeoffs
## Unified Candidate Ranking
This ranking considers every candidate together, regardless of where the idea came from. The main criteria are mechanical
fit, overlap with existing professions, implementation clarity, strategic-layer value, Shardok value, and narrative
payoff.
1. **Warden**: Best immediate fit. It gives Constitution a profession path and creates a distinctive custody/protection
identity.
2. **Quartermaster**: Best strategic depth after Warden. Logistics touches resources, armies, travel, and attrition
without adding another damage specialist.
3. **Envoy**: Best diplomacy/prisoner-system extension. Strong narrative payoff and clear Eagle-side value.
4. **Surgeon**: Best loss-mitigation role. Makes battle aftermath less binary and gives non-magical healing a place.
5. **Herald**: Strong morale and action-economy support. Distinct from Paladin if kept secular and battlefield-command
focused.
6. **Marshal**: Strong army identity, especially for formation play and troop organization, but needs careful separation
from Champion.
7. **Inquisitor**: Useful counterplay against Mage and Necromancer, though anti-magic should not become too narrow.
8. **Pathfinder**: Good campaign movement role, but should focus on routes and terrain rather than replacing Ranger
scouting.
9. **Alchemist**: Good resource and terrain manipulation fantasy, but risks crowding Mage and Engineer unless scoped
around risky transformations.
10. **Spymaster**: Strong flavor and intelligence value, but needs slow strategic effects to avoid replacing Recon.
11. **Artificer**: Fun temporary-device fantasy, but overlaps Engineer unless it owns one-shot preparation rather than
construction.
12. **Beastmaster**: Connects nicely to beast events, but overlaps Ranger unless focused on province events and wild
allies.
13. **Harbinger**: Excellent flavor, but probably later because fear/morale already touches Necromancer and Paladin
space.
The best near-term sequence is likely **Warden**, then **Quartermaster** or **Envoy**. Avoid adding another pure damage
caster until defensive, logistical, diplomatic, morale, and aftermath niches are better represented.
## Additional Candidate Details
These ideas come from looking at open mechanical space in Eagle and Shardok: logistics, loyalty, prisoners, province
events, diplomacy, battlefield control, and non-damage support.
### Marshal
**Fantasy:** The army organizer who turns a pile of units into an actual campaign force.
**Best stat fit:** Strength or Charisma
**Eagle Ability: "Muster"**
- Reorganize, reinforce, or prepare battalions more efficiently than a normal hero
- Could reduce vigor cost for Organize Troops or improve training/armament transfer efficiency
**Shardok Ability: "Command Formation"**
- Adjacent friendly units gain a small defensive or morale bonus while holding formation
- Creates a tactical identity around positioning several units together
**Why it is interesting:** Champion currently owns heroic combat and training, but not army-level coordination. Marshal
would be about disciplined formations rather than duels.
### Quartermaster
**Fantasy:** The logistics expert who keeps armies fed, paid, armed, and moving.
**Best stat fit:** Constitution or Wisdom
**Eagle Ability: "Provision"**
- Move food/gold/supplies with less waste or farther than normal
- Reduce attrition or readiness loss for armies operating away from strong provinces
**Shardok Ability: "Resupply"**
- Restore limited ammunition, repair light damage, or grant a one-turn readiness buff to a nearby unit
**Why it is interesting:** The strategic layer already has meaningful resources. A profession that manipulates logistics
would create strong choices without simply adding another combat spell.
### Envoy
**Fantasy:** The negotiator, hostage-broker, and oath-maker.
**Best stat fit:** Charisma
**Eagle Ability: "Parley"**
- Improve diplomacy outcomes, ransom terms, prisoner returns, or truce/alliance offer odds
- Could reduce the risk of ambassadors being imprisoned or create better return-prisoner rewards
**Shardok Ability: "Demand Surrender"**
- Attempt to force a damaged or isolated enemy unit to flee, with odds based on charisma and battlefield state
**Why it is interesting:** Eagle has relationships, ransoms, truces, alliances, and prisoner choices. Envoy would make
diplomacy feel like a profession rather than only a menu action.
### Spymaster
**Fantasy:** The patient handler of informants, rumors, sabotage, and false trails.
**Best stat fit:** Wisdom or Charisma
**Eagle Ability: "Infiltrate"**
- Plant a delayed intelligence effect in an enemy province, revealing troop movements or weakening support
- Could counter or complement Ranger recon without duplicating it
**Shardok Ability: "Sabotage"**
- Before or during battle, reduce one enemy unit's readiness, movement, or first action effectiveness
**Why it is interesting:** Ranger is field reconnaissance. Spymaster can be slower, political, and province-focused.
### Surgeon
**Fantasy:** The healer who saves lives after the dramatic part of the story is over.
**Best stat fit:** Wisdom or Constitution
**Eagle Ability: "Triage"**
- Reduce hero vigor loss, casualty severity, or prisoner death/execution fallout after battles
- Could improve recovery in provinces with many wounded heroes or battered battalions
**Shardok Ability: "Stabilize"**
- Prevent a nearby friendly hero unit from being captured or destroyed once per battle, leaving it routed or exhausted
instead
**Why it is interesting:** Paladin has holy support, but not mundane medical recovery. Surgeon creates a grounded support
role that can make losses less binary.
### Artificer
**Fantasy:** The maker of rare devices, siege tools, lenses, traps, and battlefield instruments.
**Best stat fit:** Wisdom or Agility
**Eagle Ability: "Prototype"**
- Invest gold and vigor to create a temporary province or battalion enhancement
- Examples: better siege readiness, scouting lenses, defensive traps, or weatherproof stores
**Shardok Ability: "Deploy Device"**
- Place a one-use trap, barricade, signal flare, or field tool on a nearby hex
**Why it is interesting:** Engineer currently owns building and repair. Artificer can own temporary inventions and
one-shot tactical preparation.
### Harbinger
**Fantasy:** The terrifying omen-bearer whose arrival changes morale before the first blow lands.
**Best stat fit:** Charisma or Wisdom
**Eagle Ability: "Portent"**
- Lower enemy support, increase unrest, or amplify the psychological effect of victories and executions
- Could be risky: fear-based rule damages diplomacy or loyalty if overused
**Shardok Ability: "Dread Standard"**
- Enemies near the Harbinger suffer morale penalties or worse flee odds
**Why it is interesting:** Necromancer has supernatural fear, but Harbinger could be political and symbolic rather than
undead-focused.
### Pathfinder
**Fantasy:** The guide who knows hidden passes, river crossings, and winter roads.
**Best stat fit:** Agility or Constitution
**Eagle Ability: "Find Passage"**
- Move armies or heroes through difficult terrain, winter, blizzards, or river-heavy borders with lower penalties
- Could create one-turn temporary travel links between neighboring provinces under specific conditions
**Shardok Ability: "Open Route"**
- Let a nearby unit ignore one terrain penalty or cross a difficult hex safely this turn
**Why it is interesting:** Ranger owns stealth and scouting. Pathfinder owns movement and campaign geography.
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
-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.
+4 -4
View File
@@ -486,7 +486,7 @@ jobs:
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
lfs: false
@@ -517,7 +517,7 @@ jobs:
runs-on: ubuntu-latest
needs: []
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
lfs: false
@@ -532,7 +532,7 @@ jobs:
uses: actions/cache@v3
with:
path: ~/.cache/bazel
key: bazel-linux-${{ hashFiles('MODULE.bazel', 'MODULE.bazel.lock', 'WORKSPACE') }}
key: bazel-linux-${{ hashFiles('MODULE.bazel', 'WORKSPACE') }}
- name: Build Shardok server
run: bazel build -c opt //src/main/cpp/net/eagle0/shardok:shardok-server
@@ -555,7 +555,7 @@ jobs:
needs: [build-eagle, build-shardok]
environment: production
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Deploy to DigitalOcean
uses: appleboy/ssh-action@v1.0.0
+1 -1
View File
@@ -136,7 +136,7 @@ Create a tool to programmatically regenerate the Eagle strategic map images with
| `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 |
| `Assets/Gameplay.unity` | Add ProvinceLabelsController, label prefab instances |
---
@@ -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.
+9 -18
View File
@@ -6,16 +6,6 @@ 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
@@ -27,7 +17,7 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
- [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
- [ ] Notify about client updates, button to come directly back
- [x] Generatedtext healing
- [x] Kill outstanding shardok requests when game is deleted
@@ -61,7 +51,7 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
### Basic Gameplay
- [ ] Tutorial & first-session onboarding
- [ ] Tutorial
- [x] In the Your Warlord panel, say what the profession is
- [x] ~~And separate panels for each profession when you encounter one~~
- [x] ~~Command tutorial for each command the first time it's clicked~~
@@ -72,16 +62,17 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
- [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?
- [ ] Shardok tutorial!
- [ ] Basic Shardok AI stuff fixed
- [x] ~~Lobby fixes~~
- [ ] Lobby fixes
- [ ] Have goals / ending
- [ ] Win condition: all other factions defeated
- [ ] Mid-game progression: King recognizes you as you gain power (generated events)
- [ ] First-session onboarding (beyond mechanics tutorial)
- [ ] Narrative hook in first few minutes - why should I care about my warlord?
- [ ] Clear first-session goal ("try to capture your first province" or similar)
- [ ] Early small victory to build momentum
- [ ] Guided first scenario vs. overwhelming sandbox?
## Nice to have
-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_visiting_town 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.
-184
View File
@@ -1,184 +0,0 @@
# Text Client Operator Guide
This guide covers operating the Eagle text client against a running Eagle server. It does not cover game strategy.
## Authentication And Startup
The text client can request and refresh its own tokens. GitHub is the recommended OAuth provider:
Start the production client with:
```bash
bazel run //src/main/scala/net/eagle0/eagle/text_client:eagle_text_client -- --tls --host prod.eagle0.net --port 443 --oauth
```
On the first run, or when no usable refresh token remains, the client prints a GitHub login URL. Paste that URL to the user and wait for them to authenticate in their browser. The callback returns to a temporary loopback listener in the text client, not to the Unity client. Never open the URL or choose an account on the user's behalf.
The client stores the access and refresh tokens in `~/.eagle0/text-client/` with owner-only permissions and refreshes the access token automatically. For an existing token location, pass both `--bearer-token-file <path>` and `--refresh-token-file <path>`. Use `--oauth-provider <provider>` only if the account uses `google`, `discord`, `apple`, `steam`, or `twitch` instead of the recommended `github` provider.
At the `eagle0>` prompt, use `lobby` to list running games and available new-game leaders. To resume a game, use:
```text
stream <game-id>
```
Wait for `subscription: success=true` before posting a command.
## Inspecting State
Use these commands before acting:
```text
status
state
commands
commands-json
```
`state` is the concise map and owned-province view. `state-json` prints the complete view. `commands-json` includes the exact fields, IDs, resource amounts, and command choices currently accepted by the server. Each entry also includes a `post-json` template for its corresponding selected command.
Generated text is stored locally during the client process. Use `text <text-id>` for a text ID without spaces, such as `text hn_105`. Province leaders in `state` are resolved through this store when their names have been received.
## Posting Eagle Commands
Use the available-command data from `commands-json` to select a command, then post a matching selected command:
```text
post-json <province-id> <SelectedCommandType> <JSON>
```
Templates contain the accepted JSON field names and default values. Replace zero
IDs, `UNKNOWN` enum values, empty collections, and other placeholders with a legal
choice from the available-command payload before posting. Where a selected command
must echo server data, such as a ransom offer, the client copies that data into the
template automatically.
For example:
```text
post-json 13 ImproveSelectedCommand {"improvementType":"ECONOMY","actingHeroId":105,"lockType":true}
post-rest 13
post-feast 13
```
The client has shortcuts for `post-rest`, `post-return`, `post-feast`, riot decisions, and battle aftermath. The authoritative selected-command schemas are defined in:
```text
src/main/protobuf/net/eagle0/eagle/api/selected_command.proto
```
Only post a selected command that is currently listed as available. A successful request prints `post-command: SUCCESS`; do not assume a command was accepted until that response or a later game update arrives.
Some actions enter a temporary subcommand state. For example, trading is operated as:
```text
post-json <province-id> VisitTownSelectedCommand {}
post-json <province-id> TradeSelectedCommand {"tradeType":"BUY_FOOD","amount":<amount>}
post-return <province-id>
```
After each step, run `commands-json` again. The server will reject a nested command, such as `TradeSelectedCommand`, if its parent state has not been entered.
## March Warnings
Marches may produce a warning before being sent. Review it, then resubmit only when intentional:
```text
post-json --force <province-id> MarchSelectedCommand <JSON>
```
`MarchSelectedCommand.marchingUnits` contains combat units. Each selected battalion is paired with a distinct hero in the same combat-unit entry; do not submit battalion-only entries.
## Shardok Battles
When a battle is active, inspect it with:
```text
shardok-units
shardok-commands
```
Post one of the currently listed command indices with:
```text
post-shardok <index>
```
Omit the optional roll value during normal play. The server then generates any
roll required by the selected command. To override that roll for deterministic
testing, append an integer value:
```text
post-shardok <index> <integer-roll>
```
`roll` is a placeholder in command usage, not a literal argument. Only an
integer is accepted, and the override has an effect only for Shardok commands
that support a client-supplied roll.
For a specific active battle:
```text
post-shardok <shardok-game-id> <index>
post-shardok <shardok-game-id> <index> <integer-roll>
```
The battle ID may be omitted only when exactly one outstanding battle currently
has postable commands. If multiple battles have commands, the client refuses the
ambiguous shorthand; use the battle ID printed by `shardok-commands`. Battles
removed from Eagle's outstanding-battle list are no longer postable, and a
Shardok update with no available commands clears the previous command list.
Use `shardok-commands-json` or `shardok-units-json` when the concise output omits a needed field.
The initial Eagle state includes the complete strategic province graph. Inspect it with:
```text
eagle-map
eagle-map-json
```
Both forms list every province and all of its neighbors by ID and name. Use the JSON form when exact IDs or
machine-readable relationships are needed; unlike March destinations, this is the complete map graph and is not
filtered by the commands currently available.
During battle setup, `shardok-commands` prints any server-recommended unit placements in order and identifies the
ordinary current command index for the next recommendation:
```text
recommended-starting-positions (ordered):
[0] unit=12 target=(3,4)
[1] unit=15 target=(4,4)
use next: post-shardok <shardok-game-id> 17
```
The list is advice, not an automatic placement batch. Review it against `shardok-map`, choose whether to use the first
recommendation, and submit that one placement with the printed `post-shardok` command. After every placement, wait for
the updated state and run `shardok-commands` again; the legal command indices and remaining recommendations may change.
The client synchronizes Shardok map definitions when it connects. To inspect the
base terrain together with current tile modifiers and occupying units, use:
```text
shardok-map <shardok-game-id>
shardok-map-json <shardok-game-id>
```
The battle ID may be omitted when the client knows about exactly one Shardok
battle. The concise map prints every coordinate's terrain plus fire, castle,
bridge, ice, and snow state. The JSON form includes the complete base map,
dynamic tile modifiers, and current active units.
## Reconnects And Shutdown
The client automatically recreates a failed gRPC stream and re-subscribes to the active game. It retains its in-process `ClientTextStore`, so a reconnect should announce:
```text
Reconnecting to game <game-id> with saved text state.
```
Followed by a successful subscription without replaying the full generated-text catalog. Do not start a second client merely because a stream reports `GOAWAY`, `UNAVAILABLE`, or `RST_STREAM`; wait for the automatic reconnect and subscription acknowledgment.
The text store is in memory. A newly launched process performs an initial text sync once, then retains byte offsets for reconnects during that process.
Use `quit` to close the client cleanly. Use `drop <game-id>` only when intentionally abandoning that game.
+234 -130
View File
@@ -1,163 +1,267 @@
# Tutorial Battle System
This document describes the current opening tutorial battle. It is based on the live implementation in `TutorialGameCreation.scala`, `TutorialBattleController.cpp`, `ShardokGamesManager.cpp`, `ResolveBattleAction.scala`, and the Unity dialogue scripts.
For the Unity tutorial UI/dialogue architecture, see `TUTORIAL_SYSTEM.md`. For copy and content notes, see `TUTORIAL_CONTENT.md`.
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.
---
## Current Flow
## Overview
When a player creates a tutorial game at the default `OpeningBattle` phase:
When a new player starts their first game in tutorial mode, they experience:
1. `TutorialGameCreation.createTutorialGame()` creates a `GameType.Tutorial(TutorialPhase.OpeningBattle)` game starting in `RoundPhase.BattleRequest`.
2. Province 14, **Onmaa**, belongs to Sadar Rakon's faction and already has a defending army.
3. Bregos Fyar's faction has a hostile attacking army, led by Ikhaan Tarn, already moving from province 31 to Onmaa.
4. `RequestBattlesAction` immediately creates the Shardok battle and tags tutorial reinforcement hero IDs `100` and `101`.
5. `GamesManager` stores the generated `TutorialBattleConfig` for this game and passes it to Shardok when the battle starts.
6. Shardok creates the battle with the configured timed reinforcement events.
7. Unity plays the active dialogue scripts from `tutorial_strategic.json` and `tutorial_battle.json`.
There is also a tutorial phase-start path. Starting after `OpeningBattle` applies precomputed tutorial setup results from `TutorialPhaseResultsLoader`; this is why the Unity post-battle trigger logic handles a "battle was never observed" auto-resolve path.
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 Setup
## Battle Configuration
### Defender: Sadar Rakon's Faction
### 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)
Configured in `src/main/resources/net/eagle0/eagle/tutorial_parameters.json`:
### Attacker (Ikhaan Tarn)
- **Heroes**: Ikhaan Tarn + 2 sworn brothers (3 total)
- **Units**:
- 1x Heavy Cavalry (600 troops, 80 training/armament)
- 1x Heavy Infantry (500 troops, 80 training/armament)
- 1x Longbowmen (300 troops, 80 training/armament)
- **Origin Province**: 32
- **Faction ID:** 3
- **Province:** 14, Onmaa
- **Starting support:** 23
- **Starting resources:** 50 gold, 4000 food
- **Heroes:** Sadar Rakon, Old Marek the Learned, Agamemnon
- **Battalions:**
- Rakon's Loyalists: Light Infantry, 529 size, 80 training, 75 armament
- Onmaa Defenders: Light Infantry, 311 size, 80 training, 60 armament
- Hunters of the Steppe: Longbowmen, 478 size, 80 training, 60 armament
### Flee Trigger
Attacker flees when ANY of these conditions are met:
- Player loses 1 unit
- 5 rounds have passed
### Attacker: Bregos Fyar's Faction
The opening attacking army is configured under Bregos Fyar's `attackingArmies`:
- **Faction ID:** 2
- **Origin province:** 31
- **Destination province:** 14, Onmaa
- **Heroes:** Ikhaan Tarn, Tall Edgtheow, Waylaid Julius, Luke the Prank-tricker
- **Battalions:**
- Doomriders: Heavy Cavalry, 592 size, 80 training, 80 armament
- The Shardok's Guard: Heavy Infantry, 507 size, 80 training, 80 armament
- Bowmen of Nikemi: Longbowmen, 291 size, 80 training, 80 armament
- Swift Sabres: Light Cavalry, 450 size, 80 training, 80 armament
Tarn's unit is made visible to the defender from the start through `TutorialBattleConfig.initial_visibilities`.
### Reinforcements
When Tarn flees, these heroes arrive as player reinforcements:
- Elena Fyar
- Hedrick
- The Boulder
---
## Reinforcements
## Implementation Architecture
The current tutorial config has two timed reinforcement events. It does not currently configure a scripted Tarn flee event.
### Phase 1: Narrative Intro System
| Event ID | Trigger | Hero ID | Hero | Battalion |
|----------|---------|---------|------|-----------|
| `elena_reinforcement_round_5` | End of round 5 or later | 101 | Elena Fyar | Fyar's Vanguard, Heavy Infantry, 535 size, 75 training, 75 armament |
| `ranil_reinforcement_round_7` | End of round 7 or later | 100 | John Ranil | Ranil's Riders, Heavy Cavalry, 458 size, 80 training, 80 armament |
**Proto Changes:**
- `game_state_view.proto`: Added `TutorialNarrativeScreen` message and `pending_narrative_screens` field
- `game_parameters.proto`: Added `TutorialNarrativeScreen` and `tutorial_narrative_screens` field
Shardok creates reinforcement units at battle creation time with `PENDING_REINFORCEMENT` status. `TutorialBattleController` activates them when their event triggers and returns a `TUTORIAL_REINFORCEMENTS_ARRIVED` action result. Unity maps that action to profession-specific dialogue triggers:
**Unity Client:**
- `NarrativeScreenController.cs`: Modal screen with title, body text, optional image
- `EagleGameController.cs`: Checks for pending narrative screens on game start
- `tutorial_reinforcement_paladin` for Elena Fyar
- `tutorial_reinforcement_engineer` for John Ranil
**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
```
The reinforcement action includes an `attacker_starting_position_index`; Shardok resolves that to currently open map positions and starts a reinforcement placement phase.
### 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.
---
## Scripted Event Controller
`TutorialBattleController` is a generic event-driven controller, even though the current tutorial battle only uses timed reinforcements.
Supported trigger types:
- `after_round`
- `units_lost`
- `damage_taken`
- `unit_killed`
Supported action types:
- `flee`
- `reinforcements`
Events are checked at the end of each round, evaluated in config order, and each `event_id` fires at most once.
---
## Battle Resolution
`RequestBattlesAction` marks tutorial opening battles with `reinforcementHeroIds = Set(100, 101)`. `ResolveBattleAction` uses that metadata so reinforcement heroes are accepted when battle results return, even though they were not part of the original defending army.
After the opening battle resolves, `ResolveBattleAction` applies `TutorialTarnDisappearsAction` for `GameType.Tutorial(TutorialPhase.OpeningBattle)`. Tarn is removed from captured/unaffiliated province state and from his faction's leaders list, matching the post-battle dialogue that he has vanished.
`TutorialBattleAutoResolve` provides a synthetic defender victory for tutorial setup paths that skip past the opening battle. In that synthetic result:
- The defender wins.
- Reinforcement heroes 100 and 101 survive.
- Tarn is marked outlawed/escaped.
- Other attacker heroes are captured.
- Defender battalions take about 30% casualties.
- Attacker battalions are destroyed.
---
## Unity Dialogue Hooks
Strategic tutorial dialogue:
- `game_started`: opening Onmaa monologue, ending with `FightButton` highlighted.
- `tutorial_battle_ended`: post-battle aftermath.
- `tutorial_rebuild_support`: support rebuilding guidance after captured-hero handling is done.
Battle tutorial dialogue:
- `shardok_placement_started`: placement and unit overview.
- `shardok_battle_running`: first player turn guidance.
- Ability/terrain triggers such as `archery_available`, `melee_available`, `ability_charge_available`, `start_fire_available`, `thunderstorm`, `duel_available`, `hide_available`, and `engineer_near_enemy`.
- Reinforcement triggers `tutorial_reinforcement_paladin` and `tutorial_reinforcement_engineer`.
- Capture triggers `enemy_hero_captured` and `friendly_hero_captured`.
- `shardok_battle_reset`: replay dialogue after a battle reset.
---
## Files To Check First
## File Summary
### New Files
| File | Purpose |
|------|---------|
| `src/main/resources/net/eagle0/eagle/tutorial_parameters.json` | Tutorial map, factions, starting provinces, armies |
| `src/main/scala/net/eagle0/eagle/service/new_game_creation/TutorialGameCreation.scala` | Opening battle setup and `TutorialBattleConfig` creation |
| `src/main/protobuf/net/eagle0/common/tutorial_battle_config.proto` | Scripted event config schema |
| `src/main/cpp/net/eagle0/shardok/server/ShardokGamesManager.cpp` | Adds pending reinforcement units and applies initial visibility |
| `src/main/cpp/net/eagle0/shardok/library/tutorial/TutorialBattleController.cpp` | Evaluates scripted events and creates Shardok action results |
| `src/main/scala/net/eagle0/eagle/library/actions/impl/action/RequestBattlesAction.scala` | Creates the opening battle and tags reinforcement hero IDs |
| `src/main/scala/net/eagle0/eagle/library/actions/impl/action/ResolveBattleAction.scala` | Resolves reinforcements and applies Tarn disappearance |
| `src/main/scala/net/eagle0/eagle/service/tutorial/TutorialBattleAutoResolve.scala` | Synthetic result for phase-start paths after the opening battle |
| `src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Tutorial/Triggers/TutorialTriggerRegistry.cs` | Converts strategic and tactical events into dialogue triggers |
| `src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Resources/Dialogues/tutorial_battle.json` | Active battle dialogue content |
| `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
- [ ] Tutorial game starts in BattleRequest and creates an Onmaa battle immediately.
- [ ] Opening strategic dialogue highlights the Fight button.
- [ ] Tarn's unit is visible to the defender from battle start.
- [ ] Placement dialogue fires during setup.
- [ ] First-turn battle-running dialogue fires on the player's first active turn.
- [ ] Elena Fyar arrives from the configured round-5 event.
- [ ] John Ranil arrives from the configured round-7 event.
- [ ] Reinforcement arrival dialogues fire once per hero.
- [ ] Battle reset clears battle dialogue completion for replay.
- [ ] Post-battle aftermath fires after the battle leaves `RunningShardokGameModels`.
- [ ] Rebuild-support dialogue waits until captured-hero handling and visible notifications are done.
- [ ] Starting a tutorial at a later phase uses precomputed setup and still reaches coherent strategic dialogue state.
- [ ] 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
}
}
```
+13 -13
View File
@@ -1,8 +1,6 @@
# Tutorial Content Guide
This document describes tutorial content. The active tutorial experience is driven by the narrative dialogue JSON files under `src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Resources/Dialogues/`.
The older step-based modal/overlay content in `TutorialContentDefinitions.cs` is currently dormant: `RegisterAll()` returns before registering any sequences. Keep that in mind when editing this file; changing `TutorialContentDefinitions.cs` will not affect the live tutorial until the early return is removed and the overlap with dialogue triggers is audited.
This document defines all tutorial content. Edit this to refine the text, then update `TutorialContentDefinitions.cs` to match.
---
@@ -183,16 +181,17 @@ These fire after the first tutorial battle ends and the player returns to the st
>
> 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:** John Ranil's step highlights `ImproveProvinceButton` and explains **Improve**. Elena Fyar's step highlights `GiveAlmsButton` and explains **Give Alms**. You must have at least **40** support in the province by January in order to collect gold and food in taxes.
**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:** `ImproveProvinceButton`, `GiveAlmsButton`
**Highlight targets:** `SupportField`
### Implementation Notes
- Both dialogues are defined in `tutorial_strategic.json`.
- `tutorial_battle_ended` fires from `TutorialTriggerRegistry.CheckTutorialBattleEnded()` when the opening battle is gone and strategic commands are available. It also handles the server-side auto-resolve path where the Unity client never observed a running Shardok model.
- `tutorial_rebuild_support` fires from `TutorialTriggerRegistry.CheckTutorialRebuildSupport()` when available commands no longer include `HandleCapturedHeroCommand` and the notification panel has no visible info.
- `ImproveProvinceButton` and `GiveAlmsButton` are runtime command-button targets registered by `CommandButtonPanelController`.
- 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)
---
@@ -211,10 +210,11 @@ These fire after the first tutorial battle ends and the player returns to the st
## Adding New Tutorials
1. Add entry to this document
2. For active tutorial content, add or edit a script in `Assets/Resources/Dialogues/tutorial_strategic.json` or `tutorial_battle.json`.
3. Ensure the trigger event exists in `TutorialTriggerRegistry.cs` or is fired directly by the relevant controller.
4. If reviving the dormant step-UI system, remove the early return in `TutorialContentDefinitions.RegisterAll()` only after auditing duplicate trigger coverage with the dialogue JSON.
5. Test the flow.
2. Update `TutorialContentDefinitions.cs`:
- For onboarding: add to `CreateOnboardingSequence()`
- For contextual: add to `RegisterStrategicTutorials()` or `RegisterTacticalTutorials()`
3. Ensure trigger event exists in `TutorialTriggerRegistry.cs`
4. Test the flow
---
-210
View File
@@ -1,210 +0,0 @@
# Tutorial Playthrough Notes
This is a working guide for playing through the Eagle0 tutorial without losing. It is written from live text-client
testing, so treat it as a practical checklist rather than a complete design reference.
## Core Rule
Do not risk Sadar Rakon unless the outcome is overwhelmingly favorable. In tutorial games, Sadar is the human faction
leader; if he is captured, the server treats the human player as defeated and the game ends.
## Strategic Goals
- Survive the opening tutorial battle.
- Rebuild Onmaa enough to stabilize support and loyalty.
- Expand carefully from Onmaa/Nikemi with enough force to avoid leader capture.
- Trigger the major strategic tutorial event where The Eagle appears. In code, this happens when Sadar controls at
least four provinces or Hedrick's faction has no provinces.
## Tactical Safety Rules
- Avoid sending Sadar alone into a tactical battle.
- If a battle turns into a long chase, reassess instead of blindly sweeping the map.
- Prefer preserving Sadar over forcing a marginal province capture.
- When the text client shows no attack command, do not assume the enemy is helpless; they may still be able to win by
holding, hiding, or outlasting the attacker.
## Known Failure
In one previous Kojaria attempt, Sadar attacked with one battalion, chased Hedrick across the map, failed to force a
decisive win, and was captured. The client did not clearly announce game over at the time; the save later showed Kojaria
held by Hedrick and Sadar listed under `captured_heroes`.
## Live Run Log
### Fresh Tutorial Run
- Started: game `-3568010721410464682`.
- Leader: Sadar Rakon.
- Current objective: start the tutorial from the opening battle and record safe choices.
- Opening battle setup:
- Rakon's Loyalists at `(8,10)`.
- Onmaa Defenders at `(7,10)`.
- Hunters of the Steppe at `(8,11)`.
- Rationale: keep the two infantry units together on the west-facing line and keep the longbowmen directly behind
them. Tarn's force begins west/southwest of the defender setup area, and Elena/John arrive later, so the opening
goal is to preserve the core army rather than overextend.
- Opening battle, day 1: no attacks were available. Ended the turn rather than starting fires or moving forward.
- Opening battle, day 2:
- Used Hunters of the Steppe for an archery attack against the visible unit at `(7,9)`.
- Used Rakon's Loyalists for an archery attack against the unit at `(6,10)`.
- Used Onmaa Defenders for melee against the unit at `(6,10)`.
- Avoided the offered duel from Rakon's Loyalists against the unit at `(7,9)`. If that unit is Tarn, the duel risks
Sadar directly before reinforcements have arrived.
- Opening battle, days 3-5:
- On day 3, only Rakon's Loyalists and Hunters of the Steppe had commands. Used both for archery against the unit at
`(6,10)`.
- On days 4 and 5, only Hunters of the Steppe had commands. Used archery against the nearest non-Tarn pressure unit
rather than retreating or entering melee.
- This is a danger signal: if Sadar's unit disappears from the command list this early, the opening formation may have
absorbed too much damage. Keep future attempts more defensive and consider moving Sadar back instead of using every
available attack.
- Elena Fyar arrived on day 6 as actor `7`, confirming the `elena_reinforcement_round_5` tutorial event. She spawned
far southwest of the fight with Holy Wave available. Moved her toward the battle first instead of spending Holy Wave
while isolated; the move consumed her action for the turn.
- John Ranil arrived on day 8 as actor `8`, confirming the `ranil_reinforcement_round_7` tutorial event.
- After John arrived:
- Moved Hunters of the Steppe back to `(10,11)` instead of taking a weak melee attack.
- Moved Elena to `(8,7)` to close with the enemy line.
- Moved John to `(6,4)` to approach from the west.
- Do not expect reinforcements to appear in immediate melee range. They need one or more turns of marching before they
can rescue the original defender units.
- Day 10 Holy Wave timing:
- Moved John adjacent to Elena first.
- Used Elena's Holy Wave only after John was adjacent, so the action could inspire more than Elena herself.
- If Holy Wave is available immediately when Elena appears, do not spend it unless she is already adjacent to a
friendly unit or there is a specific emergency reason.
- Day 11 reinforcement attack:
- Elena got melee commands against `(7,9)` and `(8,10)`. Chose `(8,10)` first because `(7,9)` had repeatedly looked
like the Tarn/front-leader unit.
- Moved John to `(9,9)`, which opened a charge against `(8,10)`.
- Used John's charge and then his follow-up melee against `(8,10)`.
- Practical rule: once Elena and John arrive, pair their attacks on the same non-Tarn unit before taking leader-risky
engagements.
- Probable defeat/reset signal:
- Continued John melee attacks against `(8,10)` through day 14.
- After the day-14 melee, the Shardok stream emitted `shardok-reset` for the same battle id
`ce7be1272b8b4456_1_df540967`.
- The battle token reset to `6` and commands returned to the original opening placement commands, while Eagle status
still reported `BATTLE_IN_PROGRESS`.
- This most likely means the battle was lost, but the text client did not surface the loss explicitly. Treat a reset
after a long battle as a probable defeat signal unless server state proves otherwise.
- For the live run, replay the reset battle with a more conservative setup and keep Sadar out of the first contact
line.
- Replay setup after reset:
- Rakon's Loyalists at `(10,11)`.
- Onmaa Defenders at `(8,10)`.
- Hunters of the Steppe at `(9,11)`.
- Rationale: put Sadar deep, use Onmaa Defenders as the first contact screen, and keep archers close enough to shoot
without becoming the front line.
- Replay tactical adjustment:
- Day 2: enemy units still reached Sadar quickly. Moved Rakon's Loyalists away instead of dueling or meleeing.
- Day 3: moved Sadar again, then moved Hunters of the Steppe out of melee contact.
- Day 4-5: continued withdrawing Sadar north/east and pulled the archer back instead of trading melee.
- Elena arrived with Sadar still alive and mobile. Move Elena east immediately; do not spend Holy Wave while isolated.
- John arrived after Elena. Use Elena for contact, march John east, and let Sadar contribute only with archery while
he remains out of melee.
- Second probable defeat/reset:
- The replay kept Sadar alive through both reinforcements and reached day 20.
- After repeated John melee against the remaining contact at `(6,7)`, Shardok emitted another `shardok-reset` for the
same battle id and returned to the original placement command list.
- This reproduced the reset behavior from the first attempt and likely means the replay was also lost. Stop treating
the current live game as a successful tutorial source until the loss condition is visible in the text client or
confirmed from server state.
- Failed castle-hold variant:
- Onmaa castle tiles are `(6,12)`, `(8,11)`, and `(10,11)`.
- Rakon's Loyalists at `(10,11)`.
- Onmaa Defenders at `(8,11)`.
- Hunters of the Steppe at `(6,12)`.
- Early result: this was much stronger than the previous formations. When no attacks were available, ended turn
instead of moving off castles. When longbow shots were offered, used Hunters of the Steppe from `(6,12)`. When
enemies stepped adjacent, used ordinary castle melee, but continued to avoid duels.
- What failed:
- The battle still reset after day 22, returning the same battle id to token `6`.
- The late phase degraded into too much Holy Wave/Reduce and too little direct pressure. Treat that as the wrong
plan for this tutorial battle.
### Successful Opening Battle
- Won in game `-3568010721410464682`, battle `ce7be1272b8b4456_1_df540967`.
- Winning setup:
- Sadar/Rakon's Loyalists at `(10,11)`.
- Onmaa Defenders at `(8,11)`.
- Hunters of the Steppe at `(6,12)`.
- Main rule: hold the three castle tiles with the starting units until the enemy gives useful attacks. Do not walk the
starting units off the castles just because movement commands are available.
- Early turns:
- End the first defender turn when only movement/fire commands are available.
- Use Hunters of the Steppe for archery whenever possible, usually into the non-Ikhaan targets at `(5,10)`,
`(5,11)`, or `(7,10)`.
- Use ordinary melee from castle tiles when the enemy steps adjacent.
- Sadar may duel non-Ikhaan targets. In the winning run, actor `4` used `CHALLENGE_DUEL` against targetUnit `2`.
- Avoid Sadar's duel against targetUnit `3` at `(9,10)`; that target behaved like Ikhaan Tarn. Ordinary melee into
that adjacent target was still used later, but the duel was skipped every time.
- Reinforcements:
- Elena arrived as actor `7`. March her directly toward the castle line instead of spending Holy Wave while isolated:
`(11,3)`, `(8,5)`, `(8,8)`, then `(8,11)`.
- John arrived as actor `8`. Rush him toward the castles: `(6,4)`, `(10,7)`, then `(8,10)`.
- Once John and Elena reach the line, use their melee attacks immediately. In the winning run both repeatedly attacked
the non-Ikhaan target at `(7,10)`.
- After that target dropped, chase the remaining left-side contact with John and Elena together. John moved to
`(4,11)`, Elena to `(5,10)`, and John finished the battle with melee at `(5,11)`.
- Do not make Holy Wave or Reduce the plan. The successful run won without using Holy Wave or Reduce in the decisive
phase; direct archery, castle melee, and reinforcement melee were enough.
- Post-battle aftermath:
- The battle resolved to Eagle commands and offered three captured heroes: `201`, `202`, and `203`.
- Recruit attempts were refused for all three, then each was imprisoned.
- After the captured-hero prompts cleared, normal strategic commands returned at province `14`, starting with
`ImproveAvailableCommand`.
### Strategic Play After Opening Battle
- Immediate recovery priorities:
- Improve Onmaa's economy and agriculture before starting a war.
- Use Alms sparingly if support is low, but watch food and treasury.
- Train existing heroes/units before marching. The opening win leaves enough time to build before attacking.
- Troop organization notes:
- Onmaa initially exposed only `LightInfantry` and `Longbowmen` as constructible in the command surface.
- An invalid organize attempt tried to top up preexisting heavy units and hit the server error
`HeavyInfantry was not among constructible battalion types Vector(LightInfantry, Longbowmen)`.
- Safe commands in the live run were:
- Top up the existing light infantry battalion.
- Create a new longbow battalion. The text-client payload that worked used numeric enum value `4` for Longbowmen.
- Do not assume existing heavy battalions can be reinforced just because they already exist in the province.
- Marching rule:
- Do not march Sadar in the first expansion army. Leave him in Onmaa and send the non-leader army instead.
- The live run sent Elena, John, Agamemnon, and Old Marek from Onmaa to Nikemi, then on to Kojaria.
- Send supplies forward after the army reaches Nikemi; do not strand the field army without food.
### Failed Hedrick Attack
- Target: Kojaria, held by Hedrick the Hedge-Merchant's faction.
- Army: four non-Sadar heroes, about 1954 troops, including the new untrained longbow battalion.
- Strategic safety result:
- This was better than marching Sadar alone. A reset here did not immediately imply player defeat because Sadar
stayed home.
- It still appears to be a losing tactical plan.
- Tactical setup:
- Placed the army on the west edge and advanced toward Hedrick's single 1000-troop stack.
- A first attempt surrounded Hedrick and used repeated melee. It reset after an infantry melee, likely a loss.
- A second attempt used more spacing:
- Move John/engineer to the firing lane first.
- Use archery whenever it appears.
- Move cavalry into contact only after ranged damage is available.
- Avoid actor 0 infantry melee and avoid making Reduce/Holy Wave the core plan.
- What worked better:
- The stable pattern was archery from actor 0 when available, archery from the new longbows when available, and
cavalry melee from Old Marek.
- Extinguishing fire with the longbows was useful when the enemy lit the cavalry contact square.
- What failed:
- Even with repeated archery and cavalry melee, the battle dragged to day 12 and then reset after another cavalry
melee.
- A single late Reduce did not visibly break the stalemate.
- The new longbow battalion had `0.0` training and `0.0` armament in the stale strategic snapshot, so it may have
contributed much less than its troop count suggested.
- Practical conclusion:
- Do not attack Hedrick immediately after creating a raw longbow battalion.
- Build a stronger army first: train and arm the new longbows, improve the economy/agriculture enough to support
better reinforcements, and consider taking easier provinces or waiting until the army has more developed units.
- If attacking Hedrick, treat his 1000-stack as a boss fight. The current safe-looking four-hero army was not enough
to finish it reliably.
-327
View File
@@ -1,327 +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 opening battle at Onmaa
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 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`).
- Dialogue completion is persisted per game through `TutorialDialogueProgressStore`, so completed scripts do not replay after a reconnect or scene reload for the same game.
**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`).
- **`Tutorial/TutorialDialogueCoordinator.cs`** — Recomputes durable dialogue triggers from the current strategic model and sends them directly to `DialogueManager`. This covers reconnects, phase-start tutorial games, and strategic beats that should be recoverable from state.
### 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
Most event-style triggers are raised via `TutorialTriggerRegistry`. Durable strategic dialogue beats are also recomputed by `TutorialDialogueCoordinator` on each model update and sent directly to `DialogueManager.TriggerDialogue()`. Both paths match against the dialogue JSON; if step-UI sequences are re-registered, only the registry path will route to them. File:line citations are for the registry unless noted; line numbers may drift.
### Bootstrap / first-session
| Trigger | Fires from | When |
|---------|-----------|------|
| `game_started` | `TutorialDialogueCoordinator.EligibleTriggers()` | Any tutorial strategic model update; dialogue progress keeps it from replaying |
| `first_battle_available` | `OnModelUpdated()` ~L204 | A `RunningShardokGameModel` first appears |
| `battle_entered` | `OnBattleEntered()` ~L793 | User clicks **Fight!** |
| `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; has a four-step strategic dialogue 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 <b>Fight!</b> to enter tactical combat.",
"highlightTarget": "FightButton",
"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 `TutorialState`; dialogue replay is controlled separately by the per-game dialogue progress key.
- Settings → "Reset Tutorials" calls `TutorialManager.ResetAllProgress()`, which also calls `DialogueManager.ResetCompletedScripts()`.
`TutorialDialogueProgressStore` separately stores completed dialogue script IDs per game under PlayerPrefs keys named `Eagle0_TutorialDialogueProgress_<gameId>`.
`DialogueManager` keeps the active game's completed script IDs in memory and saves them through `TutorialDialogueProgressStore`. `ResetCompletedScripts()` clears the current game's dialogue progress; `ClearCurrentGameProgress()` deletes the current game's persisted dialogue key.
---
## 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 **Fight!** button (`FightButton`, `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 → Visit Town + Recruit |
| 9 | `tutorial_in_town` | When you Visit Town: 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) |
| 13 | `tutorial_hero_faction_appears` | John Ranil and Elena Fyar leave Sadar's service and reappear as independent tutorial factions |
### 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.
-290
View File
@@ -1,290 +0,0 @@
# URP Migration Completion Notes
Last refreshed: 2026-06-14
## Why This Matters
Unity is steering projects away from the Built-in Render Pipeline (BiRP) toward the
Universal Render Pipeline (URP). Eagle0 has completed the URP migration and now runs
on URP:
- `ProjectSettings/GraphicsSettings.asset` points at
`Assets/Settings/URP/Eagle0URPPipeline.asset`.
- Every active quality level in `ProjectSettings/QualitySettings.asset` uses the
same URP pipeline asset.
- Legacy Post Processing Stack v2 has been removed from `Packages/manifest.json`;
keep verifying it stays out with the migration inventory.
The migration is no longer an active compatibility project. Keep this document as the
completion record and maintenance checklist for future Unity, URP, shader, material,
camera layering, or render-order changes.
## Current Recommendation
Keep URP enabled on main. Do not continue speculative URP cleanup. Use the baseline
checklist and inventory tooling only when a future change touches shaders, materials,
camera layering, render order, pipeline settings, or a visible rendering regression.
Maintenance rules:
1. Keep the visual baseline current for Connection, Eagle, Shardok, and Settings.
2. Refresh the shader/material inventory after meaningful asset or scene changes.
3. Remove or isolate unused rendering dependencies.
4. Keep the existing CI guardrails passing.
5. Treat future pipeline experiments as separate branches with explicit findings.
Known URP regressions found during playtesting have been fixed. Normal gameplay work
can proceed; future rendering PRs should be driven by observable regressions or
intentional rendering changes.
## Maintenance Workflow
### 1. Create Visual Baselines
Capture a small set of known-good views before any future change to URP settings,
custom shaders, render ordering, or third-party visual materials. These should be
reproducible enough that a human can compare screenshots after each rendering change.
Minimum baseline scenes and states:
| Area | Required View |
|---|---|
| Connection | Connect panel, stored account list, running games list |
| Eagle map | Normal province map with borders, faction highlights, selected province tooltip |
| Eagle weather | Drought/heat shimmer, flood, blizzard/rain overlays |
| Eagle beasts | Bird/animal/monster beast effects on map and notification panel |
| Eagle UI | Command panel, notifications, settings panel, generated text |
| Shardok | Terrain, grid lines, labels/icons, bridges, fires, overlays, command buttons |
| Scene transitions | Connection -> Eagle -> Shardok -> Eagle |
Keep the manual checklist and editor-only capture tool working so rendering PRs can
leave screenshots in an ignored directory.
Use `docs/URP_VISUAL_BASELINE_CHECKLIST.md` for the current manual baseline capture
set.
### 2. Refresh The Shader And Material Inventory When Needed
The inventory is useful before significant rendering work. It reports:
- all shaders under `Assets/`
- all materials and the shader each material uses
- all scenes/prefabs that reference custom shaders or Post Processing components
- all usages of Built-in-only shader features:
- `GrabPass`
- `#pragma surface`
- `UnityCG.cginc`
- `UnityUI.cginc`
- tessellation
Run `Eagle0 > Rendering > Generate URP Migration Inventory` in the Unity editor to
generate the current report. The tool writes markdown and CSV files under Unity
`Temp/urp_migration/`, so running it should not dirty project assets.
The generated CSVs include both the full material list and production material
references, which helps separate game-path shader risk from unused package/demo
assets.
`docs/URP_SHADER_DEBT_INVENTORY.md` records the current production-vs-package
classification for remaining Built-in-style shader patterns.
Current inventory snapshot:
- Render pipeline settings are URP for both graphics default and active quality.
- Post Processing Stack reference hits are still zero.
- The all-material inventory is noisy: most material assets are `Standard`, and
many Built-in-only shader hits are in third-party or package/demo assets.
- The production material-reference report is the better starting point for the
probe. Current production shader references are concentrated in TextMeshPro,
Eagle custom map/effect shaders, Shardok grid/fire materials, and a small number
of standard-material beast assets.
- The project-owned Eagle and Shardok production custom shaders have been hardened
for URP and passed Windows Unity builds after merge.
- Project-owned runtime-created Eagle/Shardok materials now use serialized shader or
material references instead of project shader-name fallbacks.
- No project-owned production shader under `Assets/Eagle` or `Assets/Shardok`
currently contains the scanned Built-in-era shader patterns.
- Package/third-party shader debt remains documented but is not on the production
path unless a future inventory run finds new references.
### 3. Verify Legacy Post Processing Usage
The project no longer depends on `com.unity.postprocessing`. The old references were
limited to an unused Orc/Ogre sample scene profile and scene-template type metadata,
not production scenes.
Keep the project free of Post Processing Stack v2 references and use the inventory
report to catch regressions.
If references reappear:
- document the exact scene/camera/profile usage
- either remove the stale dependency or plan the equivalent URP Volume settings
### 4. Keep Drought Effects URP-Friendly
The old `Eagle/HeatShimmer` shader used a Built-in-style `GrabPass`, but shimmer was
already disabled in `DroughtEffect` because it did not work well with the UI Canvas.
Drought now intentionally uses the animated sun overlay as its current visual signal.
Keep drought visuals free of `GrabPass` unless we intentionally add a new
URP-friendly effect.
If stronger drought visuals are needed later, prefer:
- an animated transparent texture overlay clipped like the existing province particles
- a small particle/sprite effect around the sun icon
- a URP renderer feature only if screen-space distortion is truly worth the complexity
### 5. Leave Third-Party Shader Conversion Demand-Driven
The riskiest third-party shader set is Polytope Studio. Do not hand-convert package
shader sets speculatively.
`docs/URP_THIRD_PARTY_ASSET_AUDIT.md` records the current production GUID-reference
scan. The important result is that production assets use Polytope character prefabs
for Eagle beast effects, but the Polytope environment/water assets that still contain
`GrabPass` were not found on the production path.
If a future visible regression or inventory update makes third-party conversion
necessary, record:
- current installed version
- whether a URP-compatible version exists
- whether upgrading is license/account-accessible
- whether the package is still used in production scenes
### 6. Keep Probe Guidance For Future Pipeline Experiments
The original disposable probe has served its main purpose: URP is enabled on main and
the biggest visual regressions were fixed in focused PRs. Keep the probe playbook as
a template for future risky pipeline experiments, not as current migration guidance.
Use `docs/URP_PROBE_PLAYBOOK.md` when we need to measure a future Unity or render
pipeline change cheaply before committing to a mergeable branch.
## Known Maintenance Risks
| Risk | Why It Matters | Right-Now Action |
|---|---|---|
| `UnityUI.cginc` usage in package shaders | URP does not provide the same include path/semantics for hand-authored shaders | Leave package-managed TMP shaders alone unless a visible regression appears. |
| `GrabPass` in unused package shaders | No direct URP equivalent | Ignore until production starts referencing those assets. |
| Surface shaders in third-party packages | URP does not support Built-in surface shader generation | Covered by beast material compatibility on current production paths; convert only for a visible regression. |
| Post Processing Stack v2 | Replaced by URP Volume system | Keep package and runtime/project-setting references from returning. |
| Scene lighting | URP lighting/shadow settings differ from BiRP | Baseline and rebake only for visible regressions or intentional lighting work. |
| Render ordering | Shardok and Eagle overlays depend on careful layering | Include affected overlays in baseline whenever render order changes. |
## Follow-Up Strategy
Main now carries the completed URP switch. Future rendering work should be split by
observable behavior:
- **Compatibility fixes**: small PRs for specific pink/missing/dark assets.
- **Render ordering fixes**: small PRs for Shardok/Eagle layer order regressions.
- **Inventory/docs updates**: docs-only or editor-tool PRs that do not alter runtime
visuals.
- **Pipeline experiments**: isolated branches using the probe playbook, with findings
copied into a mergeable follow-up.
This keeps the project from accumulating another broad, hard-to-revert rendering PR.
## Completed Phases
### Phase 0: Preparation And Switch
- Visual baseline checklist/screenshots: complete, keep current
- Repeatable shader/material inventory: complete, keep current
- Post Processing Stack usage decision: complete, keep removed
- Drought visual decision: complete, use non-GrabPass visual signal
- Third-party URP availability check: documented
- Disposable URP probe findings: superseded by the merged URP switch
- CI guardrails for pipeline settings, runtime shader lookup, project shader
patterns, and beast-material compatibility: complete
### Phase 1: Pipeline Setup
- Install/configure URP: complete
- Create URP Pipeline Asset and Renderer Asset: complete
- Configure forward rendering, shadows, HDR, and renderer features: complete enough
for current gameplay paths
- Run Unity's Render Pipeline Converter: complete for the merged switch
- Keep a record of every auto-converted material: use the inventory/audit tools
### Phase 2: Runtime Visual QA And Cleanup
Project-owned custom shader conversion, shader-name fallback cleanup, and deletion
cleanup are complete. Current CI coverage keeps the URP pipeline assigned, required
runtime shader references serialized, required player-runtime-only shaders included,
project-owned Eagle/Shardok shaders free of scanned Built-in-only patterns, key
Addressables labels covered, and major Eagle/Shardok visual wiring guarded.
Runtime visual QA was completed during playtesting. Known regressions from that pass
were fixed before the migration was called done.
Follow-up notes:
- Use `docs/URP_VISUAL_BASELINE_CHECKLIST.md` for each future rendering follow-up.
- Keep screenshots in an ignored local directory, not in git.
- Treat package/third-party shader conversions as response work for visible
regressions or new production references, not as speculative cleanup.
### Phase 3: GrabPass And Special Effects Maintenance
- Keep drought visuals on the current animated sun overlay unless a new URP-friendly
effect is intentionally added.
- Reimplement or replace the Polytope `PT_Water_Shader` GrabPass shaders if they are
ever become used by production assets.
- Avoid leaving screen-space effects until the end; they are likely to drive renderer
feature requirements.
### Phase 4: Third-Party Shaders Maintenance
- Upgrade packages where vendor URP shaders exist and a production need appears.
- Convert remaining PBR/toon/vegetation shaders manually only for production
references or visible regressions.
- Prefer replacement or deletion for unused demo-only assets.
### Phase 5: Materials, Lighting, And Post Processing Maintenance
- Batch-update materials only for production paths or visible regressions.
- Replace Post Processing Stack v2 with URP Volumes only if a production need returns.
- Rebake lighting for production scenes when scene lighting is intentionally changed.
- Tune shadow and HDR settings when visual QA or performance work justifies it.
### Phase 6: Validation Maintenance
Required visual QA for future rendering changes:
- Connection lobby
- Eagle map and command flows
- Eagle weather/beasts/notifications
- Shardok battle map overlays, unit text/icons, bridges, fires
- Scene transitions
- Mac and Windows builds
- Performance sanity check
## Rough Effort
| Work | Status |
|---|---|
| Preparation/probe | Complete |
| Pipeline setup | Complete |
| Core custom shaders | Complete |
| Runtime shader/build guardrails | Complete; project-owned runtime paths use serialized shader/material references |
| GrabPass/special effects | Deferred until a production reference or new effect need appears |
| Third-party shaders/materials | Covered by runtime compatibility and visual QA unless a visible regression appears |
| Lighting/post-processing | No current Post Processing Stack dependency; rebake/tune only for visible regressions |
| QA/polish | Completed for the migration; repeat for future rendering changes |
## Key Reminders
- URP is already enabled on main; keep follow-up work narrowly scoped.
- Project-owned shader-name fallbacks should stay out of runtime-created materials;
use serialized shader/material references instead.
- `Shader.Find` remains acceptable for Unity/URP-provided shader names inside
third-party compatibility shims, where there is no project asset to serialize.
- Do not hand-convert package shaders unless runtime visual QA exposes a concrete
regression or a new production reference appears.
- Keep C# changes minimal; current rendering scripts mostly set material properties.
- Preserve recent Shardok render-order behavior while changing pipelines.
- Treat screenshots as migration tests; compilation alone is not meaningful here.
-133
View File
@@ -1,133 +0,0 @@
# URP Probe Playbook
Last refreshed: 2026-06-14
This playbook is historical reference for the disposable URP probe described in
`docs/URP_MIGRATION_PLAN.md`. The URP migration is complete; keep this only as a
template for future risky render-pipeline experiments. A probe branch is not a
production branch and should not be merged as-is.
## Goals
- Use the already-installed URP package in an isolated branch.
- Create the minimum URP pipeline/renderer assets needed to enter Play Mode.
- Run Unity's Render Pipeline Converter.
- Record compile errors, pink materials, broken shaders, and visual regressions.
- Preserve findings in docs, then discard or reset the probe branch.
## Branch Shape
Use two branches:
| Branch | Purpose | Mergeable |
|---|---|---|
| `codex/urp-probe` | Disposable package/settings/material conversion experiment | No |
| `codex/urp-probe-findings` | Small docs-only summary copied from the probe | Yes |
Do not open a PR from `codex/urp-probe` unless it is clearly marked draft and
experimental. Prefer opening the PR from `codex/urp-probe-findings`.
## Before Starting
1. Confirm `origin/main` is fresh.
2. Confirm the worktree is clean.
3. Capture or review the baseline checklist in
`docs/URP_VISUAL_BASELINE_CHECKLIST.md`.
4. Generate a current inventory with
`Eagle0 > Rendering > Generate URP Migration Inventory`.
5. Keep the inventory output under Unity `Temp/urp_migration/`; do not commit
generated reports unless they are intentionally summarized.
## Probe Steps
1. Create `codex/urp-probe` from `origin/main`.
2. Confirm `com.unity.render-pipelines.universal` is installed in
`Packages/manifest.json`.
3. If testing new pipeline settings, create alternate URP Pipeline/Renderer assets
under a clearly named temporary folder.
4. Assign the alternate URP pipeline asset in:
- `ProjectSettings/GraphicsSettings.asset`
- `ProjectSettings/QualitySettings.asset`
5. Run Unity's Render Pipeline Converter only if the experiment intentionally tests
material conversion.
6. Enter Play Mode from `Assets/Scenes/Main.unity`.
7. Exercise the baseline views:
- Connection lobby
- Eagle map
- Eagle weather and beast effects
- Shardok terrain, grid, labels, bridges, fires, overlays
- Connection -> Eagle -> Shardok -> Eagle transitions
8. Record the exact failure mode for every broken visual:
- compile error
- pink material
- invisible mesh or sprite
- wrong render order
- lighting/shadow difference
- input or scene-load regression
## Expected High-Risk Areas
- Eagle map rendering:
- province colors, borders, and ocean animation
- weather overlays and province particles
- beast effects on the map and in notifications
- Shardok map rendering:
- terrain, bridges, fire overlays, and grid overlays
- foreground UI/effect overlays
- unit labels/icons over fires, bridges, and one-shot animations
- Third-party 3D materials:
- Polytope Studio character prefabs used by Eagle human-type beast effects
- RRFreelance Orc/Ogre materials used by `OgreEffect`
- Render ordering in Shardok:
- unit text/icons versus fires
- unit text/icons versus bridges
- selection/command overlays versus labels
## Findings Template
Copy this template into the mergeable findings branch after the probe:
```markdown
# URP Probe Findings
Date:
Unity version:
URP package version:
Probe branch commit:
## Summary
- Entered Play Mode:
- Connection usable:
- Eagle usable:
- Shardok usable:
- Build attempted:
## Converter Results
- Materials converted:
- Materials left pink:
- Shader compile errors:
## Visual Regressions
| Area | Expected | Actual | Likely Cause | Next Action |
|---|---|---|---|---|
## Required Migration PRs
1.
2.
3.
## Notes
-
```
## After The Probe
1. Copy findings into a docs-only branch.
2. Delete or abandon `codex/urp-probe`.
3. Do not merge probe-generated package/settings/material churn into main until the
findings have been reviewed and broken visuals have owner PRs.
-145
View File
@@ -1,145 +0,0 @@
# URP Shader Debt Inventory
Last refreshed: 2026-06-14
This inventory classifies remaining Built-in Render Pipeline shader patterns after
the completed URP switch. The project is running URP, but some package shader source
files still use Built-in-era helpers or features. That is package/vendor debt, not
evidence that the old pipeline is active and not an active migration blocker.
## Scan Method
The scan looked for these patterns in Unity shader-like assets:
- `GrabPass`
- `#pragma surface`
- `UnityCG.cginc`
- `UnityUI.cginc`
- `UNITY_MATRIX_`
- `sampler2D _GrabTexture`
- `tessell`
- `Tessellation`
It then mapped every matching shader asset to its Unity GUID and scanned production
serialized assets under:
- `Assets/Scenes/`
- `Assets/Eagle/`
- `Assets/Shardok/`
- `Assets/ConnectionHandler/`
- `Assets/UI/`
- `Assets/common/`
- `Assets/Tutorial/`
- `Assets/Resources/`
- `Assets/Materials/`
This catches serialized production references. It does not prove runtime code never
loads an asset by path, but it separates game-path shader debt from package/demo
noise well enough to decide whether future rendering work is needed.
## Summary
| Bucket | Shader Assets With Built-in Patterns | Serialized Production References |
|---|---:|---:|
| Project-owned production shaders | 0 | 0 referenced |
| Project-owned runtime-loaded shaders | 0 | 0 serialized refs |
| Project-owned unreferenced shaders | 0 | 0 referenced |
| TextMeshPro package shaders | 17 | 1 referenced |
| Third-party/package shaders | 25 | 0 referenced |
Project-owned runtime-created materials now use serialized shader or material
references for Eagle weather, Eagle fireworks, Shardok foreground UI/effects, and
Shardok foreground text. Remaining production `Shader.Find` usage is limited to
Unity/URP-provided shader names inside `BeastMaterialCompatibility`, plus
third-party package code outside our ownership.
## Completed Project-Owned Shader Hardening
These production shaders have been converted to URP-compatible HLSL and passed the
Windows Unity build after merge.
| Shader | Production References | Completion Notes |
|---|---|---|
| `Assets/Eagle/Shaders/ProvinceMapShader.shader` | `Assets/Eagle/Materials/ProvinceMapMaterial.mat` | Converted with province color, border, ocean, and UI clipping behavior preserved. |
| `Assets/Eagle/Shaders/ProvinceWeatherMapShader.shader` | `Assets/Eagle/Materials/ProvinceWeatherMapMaterial.mat` | Converted with weather lookup, overlay alignment, clipping, and initialized effect/tint values. |
| `Assets/Eagle/Shaders/ProvinceWeatherShader.shader` | `Assets/Eagle/Weather/BlizzardEffect.mat`, `Assets/Eagle/Weather/DroughtEffect.mat`, `Assets/Eagle/Weather/FloodEffect.mat` | Converted for the live drought/flood/blizzard material path. |
| `Assets/Eagle/Shaders/ProvinceParticleShader.shader` | `Assets/Eagle/Effects/ProvinceParticleMaterial.mat` | Converted for Eagle map province particles. Weather effect prefabs serialize the shared particle material instead of relying on shader-name fallback. |
| `Assets/Eagle/Shaders/ClipRectParticleUnlit.shader` | `Assets/Eagle/Effects/Fireworks.prefab`, `Assets/Eagle/Effects/ParticleAlphaBlendMaterial.mat`, `Assets/Eagle/Effects/ParticleStandardUnlitMaterial.mat` | Converted for Eagle one-shot effects and clipped particle UI paths; Fireworks serializes this shader instead of using shader-name fallback. |
| `Assets/Eagle/maskShader.shader` | Formerly `Assets/Eagle/Materials/map_color_nolabels.mat`, `Assets/Eagle/Materials/map_color_whitened.mat` | Deleted after confirming the serialized material path was stale. |
| `Assets/Hex Mesh Shader.shader` | None | Deleted after confirming no serialized or code references to the asset, shader name, or GUID. |
| `Assets/Shardok/ShardokFireOverlay.shader` | `Assets/Shardok/ShardokFireOverlay.mat` | Converted with the Shardok fire overlay layering path preserved. |
| `Assets/Shardok/Shaders/ForegroundUIOverlay.shader` | Serialized Shardok foreground UI/effect shader references | URP pass is serialized on the Shardok scene/container and included in `ProjectSettings/GraphicsSettings.asset`; the stale Built-in fallback pass has been removed. |
| `Assets/TextMesh Pro/Resources/Shaders/TMP_SDF Overlay.shader` | Serialized Shardok foreground text shader references | Serialized on the Shardok scene/container and included in `ProjectSettings/GraphicsSettings.asset` for required foreground text overlay material. |
## CI Guardrails
- `URPPipelineSettingsTests` keeps Graphics and Quality settings on the same URP
pipeline asset.
- `RuntimeShaderValidationTests` keeps required runtime shader assets present,
serialized Shardok foreground shader references assigned, and player-runtime-only
shaders listed in Always Included Shaders.
- `WeatherEffectMaterialTests` keeps Eagle flood/blizzard prefabs wired to the
shared URP province particle material and prevents shader-name fallbacks from
returning to those runtime paths.
- `EpidemicEffectMaterialTests` keeps Eagle epidemic particles wired to the shared
clipped particle material.
- `EagleMapMaterialWiringTests` keeps Eagle map/weather controllers wired to the
project map materials and expected URP shaders.
- `ProjectShaderPatternTests` fails if project-owned Eagle/Shardok shaders
reintroduce the scanned Built-in-only patterns.
- `RuntimeShaderFindUsageTests` fails if project-owned Eagle/Shardok runtime
scripts reintroduce shader-name lookups outside documented compatibility shims.
- `BeastMaterialCompatibilityTests` keeps runtime third-party beast material
conversion covered.
- `ProvinceBeastsControllerTests` keeps Eagle beast content names routed to
specific animated effect prefabs instead of silently falling back to the generic
circling-bird effect.
- `FireworksEffectTests` keeps the fireworks prefab wired to the project-owned
clipped particle shader and prevents shader-name fallback from returning.
- `TutorialSpriteAssetTests` keeps tutorial TMP sprite assets and the shared dialogue
panel instruction sprite asset renderable.
- `ProvinceActionAnimatorSpriteTests` keeps Eagle action animation sprite fields
assigned in the Eagle scene.
- `PostProcessingStackRemovalTests` keeps legacy Post Processing Stack v2 package
and runtime/project-setting references from returning.
- `ProductionShaderReferenceTests` keeps shaders with Built-in-only patterns, and
materials that use them, off the production serialized asset path except for the
package-managed TextMeshPro shader family.
## Highest Priority: Referenced Project-Owned Shader Debt
No project-owned production shader under `Assets/Eagle` or `Assets/Shardok`
currently contains the scanned Built-in-era shader patterns.
| Shader | Patterns | Production References | Recommended Action |
|---|---|---|---|
| None | None | None | No active migration work. Re-run inventory after future rendering changes. |
## Referenced Package Shader
| Shader | Patterns | Production References | Recommended Action |
|---|---|---|---|
| `Assets/TextMesh Pro/Resources/Shaders/TMP_Sprite.shader` | `UnityCG.cginc`, `UnityUI.cginc`, `UNITY_MATRIX_` | 16 tutorial sprite assets under `Assets/Tutorial/Sprite Assets/` | Leave alone unless Unity/URP surfaces a visible TextMeshPro sprite regression. Prefer package/vendor updates over hand-editing TMP package shaders. |
## Third-Party And Package Hits Without Serialized Production References
These files still contain Built-in-only shader patterns, but the current GUID scan
did not find serialized production references. Treat them as package/demo cleanup,
not as urgent game-path blockers.
| Package Area | Examples | Patterns | Recommended Action |
|---|---|---|---|
| Polytope Studio characters/weapons/props | Modular NPC, armor, weapons, props shaders | `#pragma surface`, `UnityCG.cginc` | Keep runtime beast material compatibility in place; only import/replace vendor URP materials if we intentionally remove the compatibility shim. |
| Polytope Studio environment/water | Water, vegetation, rock shaders | `GrabPass`, `#pragma surface`, `UnityCG.cginc`, tessellation | Ignore unless production starts referencing these assets. Water shaders are the only remaining `GrabPass` hits. |
| RRFreelance Orc/Ogre | Ogre, armor, weapon shaders | `#pragma surface`, `UnityCG.cginc` | Current production references are handled by compatibility/material fixes; do not hand-convert unless a visible ogre regression returns. |
| TextMeshPro package shaders | TMP SDF/bitmap/surface shaders | `UnityCG.cginc`, `UnityUI.cginc`, `UNITY_MATRIX_`, surface shader variants | Prefer package updates. Avoid editing package shaders unless a concrete TMP visual regression exists. |
| Clown.fat and TileableBridgePack | Toony shaders, bridge cutout shader | `#pragma surface`, `UnityCG.cginc`, `UNITY_MATRIX_` | Leave as low priority unless a production reference appears. |
## Maintenance Guidance
Runtime visual QA for the URP migration was completed during playtesting and known
regressions from that pass were fixed. Leave unreferenced third-party/package shaders
alone until they have a visible regression or production reference.
Future rendering PRs should use `docs/URP_VISUAL_BASELINE_CHECKLIST.md` and include
affected screenshots in an ignored local directory, not in git.
-122
View File
@@ -1,122 +0,0 @@
# URP Third-Party Asset Audit
Last refreshed: 2026-06-14
This audit scopes third-party visual packages after the completed URP migration. It
is based on a GUID reference scan from production Unity assets under:
- `Assets/Scenes`
- `Assets/Eagle`
- `Assets/Shardok`
- `Assets/ConnectionHandler`
- `Assets/UI`
- `Assets/common`
- `Assets/Tutorial`
- `Assets/Resources`
- `Assets/Materials`
The scan maps GUIDs from each package folder, then finds production scenes,
prefabs, materials, controllers, assets, and scripts that reference those GUIDs.
It does not prove runtime code never loads assets by path, but it catches the
serialized references that matter most for future render-pipeline maintenance.
## Summary
| Package | Production Use | URP Risk | Notes |
|---|---:|---|---|
| Polytope Studio | 20 refs / 18 assets | Medium | Used by Eagle human-type beast effects. Runtime beast-material compatibility converts active renderers to URP materials; production refs are character prefabs, not environment/water assets. |
| GUI Pro Kit Fantasy RPG | 138 refs / 58 assets | Low | Production use is UI sprites/icons. No package shader dependency found in production refs. |
| Modern UI Pack v4.2.0 | 23 refs / 6 assets | Low | Production use is UI textures/icons. |
| RRFreelance Orc/Ogre | 2 refs / 2 assets | Medium | Ogre effect uses custom Built-in surface shaders in source assets, but active renderers are covered by runtime beast-material compatibility under URP. |
| DungeonMonsters2D | 12 refs / 12 assets | Medium | 2D monster prefabs for Eagle beast effects. Expected to be mostly SpriteRenderer/Animator; keep covered in beast-effect visual checks. |
| Animal pack deluxe v2 | 7 refs / 7 assets | Medium | Elephant effect and controller refs. Verify lit-material conversion. |
| Animal pack deluxe | 24 refs / 24 assets | Medium | Multiple Eagle animal beast effects. Verify lit-material conversion. |
| AfricaAnimalsPackLowPoly V2 | 10 refs / 10 assets | Medium | Eagle beast effects and controllers. Verify lit-material conversion. |
| AfricaAnimalsPackv1 | 8 refs / 8 assets | Medium | Eagle beast effects and controllers. Verify lit-material conversion. |
| AustraliaAnimalsPackv1 | 6 refs / 6 assets | Medium | Eagle beast effects and controllers. Verify lit-material conversion. |
| DinoPackLowPolyV1 | 3 refs / 3 assets | Medium | Velociraptor effect and controller. Verify lit-material conversion. |
| HEROIC FANTASY CREATURES FULL PACK Vol 2 | 12 refs / 12 assets | Medium | Hippogryph effect and controller. Verify lit-material conversion. |
| TileableBridgePack | 0 refs | Low | No production serialized refs found. Shardok bridge visuals appear to use project-owned/runtime assets instead. |
| Stylize Water Texture | 1 ref / 1 asset | Low | Eagle province map material uses one water texture, not a shader. |
| Terrain Hexes | 2 refs / 1 asset | Low | Shardok scene/container use one rock texture. |
| Hex Tiles | 0 refs | Low | No production serialized refs found. |
| 4000_Fantasy_Icons | 273 refs / 117 assets | Low | Sprite/icon assets only. |
| StrategyGameIcons | 21 refs / 12 assets | Low | Sprite/icon assets only. |
| Clown.fat | 14 refs / 10 assets | Medium | Clown effects and animation clips; also retargeted by Polytope human effects. |
| Dragon | 2 refs / 1 asset | Medium | Dragon effect mesh/animation. Verify material conversion. |
| HoneyBadger | 2 refs / 1 asset | Medium | Honey badger effect mesh/animation. Verify material conversion. |
| Raccoon | 2 refs / 1 asset | Medium | Raccoon effect mesh/animation. Verify material conversion. |
## Runtime Beast Findings
### Polytope Studio
Production assets reference Polytope character prefabs for Eagle map effects:
- `Assets/Eagle/Effects/ArcherEffect.prefab`
- `Assets/Eagle/Effects/GiantEffect.prefab`
- `Assets/Eagle/Effects/KnightEffect.prefab`
- `Assets/Eagle/Effects/MilitiaEffect.prefab`
- `Assets/Eagle/Effects/PeasantEffect.prefab`
- `Assets/Eagle/Effects/SoldierEffect.prefab`
These prefabs ultimately use `PT_NPC_Mat`, which references
`Polytope Studio/Lowpoly_Characters/Sources/Modular_NPC/Shaders/PT_Modular_NPC_Shader_PBR.shader`.
That shader uses Built-in surface shader generation and is not URP-ready as-is.
At runtime, Eagle beast effects pass their renderers through
`BeastMaterialCompatibility.ConfigureRenderer`, which replaces non-URP materials
with URP/Lit materials and bakes Polytope palette textures when needed. Treat this
as covered by the compatibility path unless a future visual regression appears.
Polytope environment and water shaders still exist in the project and include
Built-in-only features such as `GrabPass`, `UnityCG.cginc`, surface shaders, and
tessellation. However, this scan found no production serialized references to the
Polytope environment or water assets. Treat them as demo/package risk, not current
runtime risk, unless a later inventory run finds production references.
### RRFreelance Orc/Ogre
Production assets reference the Orc/Ogre package from:
- `Assets/Eagle/Effects/OgreEffect.prefab`
- `Assets/Eagle/Effects/OgreMapAnims.controller`
The custom materials reference package shaders that use Built-in surface shader
generation in the source assets:
- `RRFreelance-Characters/Orc-Ogre/Shaders/OgreShader.shader`
- `RRFreelance-Characters/Orc-Ogre/Shaders/ArmorShader2sided.shader`
- `RRFreelance-Characters/Orc-Ogre/Shaders/WeaponShader.shader`
At runtime, the Eagle monster-effect path also uses
`BeastMaterialCompatibility.ConfigureRenderer`, so these renderers should be
left on the compatibility path unless the Ogre effect becomes pink, dark, or
otherwise visibly broken.
## Maintenance Guidance
The URP migration is complete; do not convert third-party packages speculatively.
1. Keep the runtime beast-effect compatibility path covered by existing tests and
future visual checks when beast rendering changes.
2. If compatibility leaves a beast pink, dark, or visibly broken, replace the affected
materials with URP/Lit materials before touching unused demo-only shader packs.
3. Leave Polytope water/vegetation/environment conversion until a production
reference appears. They are noisy in the shader inventory but not currently on
the game path.
4. Include Eagle beast effects in the visual baseline. They cover most third-party
3D package risk in one place.
5. Treat UI sprite packs as low pipeline risk. Their risk is texture import/render
ordering, not shader conversion.
## Vendor Version / Access Notes
Known versions from existing repo docs:
- Modern UI Pack: `v4.2.0`
Most other package versions are not recorded in repo-local metadata. Before hand
converting large third-party shader sets, check the Unity Asset Store or vendor
account for URP-compatible updates. This especially matters for Polytope Studio,
because the package contains many Built-in surface shaders even though only a
small character subset is currently used by production assets.
-61
View File
@@ -1,61 +0,0 @@
# URP Visual Baseline Checklist
Use this checklist before any future branch changes render-pipeline settings, custom
shaders, materials, lighting, camera layering, or render order. The URP migration is
complete; this checklist is now a maintenance tool. The goal is not exhaustive
gameplay QA, but a stable set of visual states that can be compared by eye after
rendering changes.
Save screenshots under:
`src/main/csharp/net/eagle0/clients/unity/eagle0/Temp/urp-baselines/`
That directory is ignored by git.
Use `Eagle0 > Rendering > Capture URP Baseline Screenshot` in the Unity editor to
write a timestamped Game view capture to that directory.
## Capture Setup
- Use the same Unity version as the branch under test.
- Start from `Assets/Scenes/Main.unity`.
- Use the same Game view resolution for every capture in a comparison set.
- Capture the full Game view, not cropped panels.
- Name files with a stable prefix and short state name, for example
`main_01_connection_accounts.png` and `branch_01_connection_accounts.png`.
- If a view depends on server data or an account, write a short note next to the
screenshot naming the game/account/state used.
## Required Captures
| ID | Area | State | Must Verify |
|---|---|---|---|
| 01 | Connection | Connect panel with stored accounts visible | Background color, panel layout, text contrast, profession icons |
| 02 | Connection | Running games list visible | Row icons, timestamps, buttons, selected/hover states if practical |
| 03 | Eagle map | Normal province map after joining a game | Province colors, borders, ocean animation frame, faction highlights |
| 04 | Eagle map | Province hovered/selected | On-map province tooltip, selected outline, side-panel readability |
| 05 | Eagle weather | Drought province | Heat shimmer/drought signal remains visible and does not obscure UI |
| 06 | Eagle weather | Flood/rain or blizzard province | Weather overlay color, particle ordering, map readability |
| 07 | Eagle beasts | Beast effect on map | Beast animation visible in Game view and positioned over province |
| 08 | Eagle beasts | Beast notification open | Animated beast visible in notification and not hidden behind panel art |
| 09 | Eagle UI | Command panel with generated text | Text glyphs, fallback glyphs, panel clipping, button states |
| 10 | Eagle UI | Settings panel open | Top-right settings button, Escape behavior, modal layering |
| 11 | Shardok setup | Initial placement | Terrain tiles, grid lines, unit labels/icons, placement overlays |
| 12 | Shardok battle | Unit selected with destination overlays | Overlay labels/icons draw above terrain, bridges, fires, and highlights |
| 13 | Shardok battle | Bridge and fire visible near units | Bridge/fire render order does not cover unit labels/icons |
| 14 | Shardok battle | Command buttons visible | Right-side UX, disabled/enabled button states, tooltip readability |
| 15 | Scene transition | Eagle to Shardok to Eagle | No black/blank frame persists, returning Eagle map is visually intact |
## Acceptance Notes
For each rendering follow-up, record:
- Unity version and branch name.
- Screenshot directory.
- Any pink/missing materials.
- Any invisible UI, labels, particle effects, bridges, fires, or weather effects.
- Any render-order differences, especially Shardok overlays and Eagle beast effects.
- Whether the difference is acceptable, needs shader work, or needs asset/material work.
Do not merge a rendering branch until the affected Connection, Eagle, Shardok, or
Settings baseline captures are visually acceptable.
+51 -119
View File
@@ -4,36 +4,17 @@ This document describes which quests the AI attempts to complete proactively to
## 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.
The AI attempts to complete quests via `FulfillQuestsCommandSelector`, which is invoked by `MidGameAIClient.chosenFulfillEasyQuestsCommand`. The AI only considers quests from unaffiliated heroes in provinces ruled by a faction leader.
## Quests the AI Actively Completes
This section lists quests with direct `QuestCommandChooser` handlers in `FulfillQuestsCommandSelector`.
### Diplomacy Quests
| Quest | Handler | Conditions |
|-------|---------|------------|
| `AllianceQuest` | `AllianceQuestCommandChooser` | Target faction must meet trust conditions for alliance and not already be in an alliance |
| `TruceWithFactionQuest` | `TruceWithFactionQuestCommandChooser` | Target faction must meet trust conditions for truce |
| `TruceCountQuest` | `TruceCountQuestCommandChooser` | Picks a random faction that meets trust conditions and isn't already in a truce/alliance |
| `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
@@ -43,99 +24,72 @@ This section lists quests with direct `QuestCommandChooser` handlers in `Fulfill
| `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
### Province Development 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 Visit Town 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 chooses Visit Town 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 |
| `ImproveAgricultureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Agriculture type |
| `ImproveEconomyQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Economy type |
| `ImproveInfrastructureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Infrastructure type |
### Other Quests
| Quest | Handler | Conditions |
|-------|---------|------------|
| `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
## Quests the AI Does Not Attempt to Complete
These are not in `FulfillQuestsCommandSelector`, but other AI command-selection code can still bias toward completing them.
The following quests have no handler in `FulfillQuestsCommandSelector` and must be completed naturally through gameplay:
| Quest | Handler | Notes |
|-------|---------|-------|
| `SuppressRiotByForceQuest` | `CommandChoiceHelpers.handleRiotSelectedCommand` | When this quest exists and the faction has battalions, the AI prefers CrackDown over Give when handling riots. |
### Combat/Military Quests
- `DefeatFactionQuest` - Defeat a specific faction
- `GrandArmyQuest` - Accumulate a large number of troops
- `UpgradeBattalionQuest` - Upgrade a battalion to minimum armament/training
- `WinBattleOutnumberedQuest` - Win a battle while outnumbered
- `WinBattlesQuest` - Win a number of battles
- `RescueImprisonedLeaderQuest` - Rescue an imprisoned leader from another faction
## Quests the AI Does Not Yet Attempt to Complete
### Expansion Quests
- `ExpandToProvincesQuest` - Expand to control a certain number of provinces
- `SpecificExpansionQuest` - Conquer a specific province
- `BorderSecurityQuest` - Have troops in a border province
The following quests have no handler and must be completed naturally through gameplay. They are listed in rough priority order for future implementation.
### Prisoner Quests
- `ExecutePrisonerQuest` - Execute a specific prisoner
- `ExilePrisonerQuest` - Exile a specific prisoner
- `ReleasePrisonerQuest` - Release a specific prisoner
- `ReturnPrisonerQuest` - Return a prisoner to their faction
- `ReleaseAllPrisonersQuest` - Release all prisoners
### Planned for Implementation
### Province Order Quests
- `DevelopProvincesQuest` - Maintain provinces in Develop order for months
- `MobilizeProvincesQuest` - Maintain provinces in Mobilize order for months
- `RestProvinceQuest` - Use the Rest command in a specific province
None currently planned.
### Reconnaissance Quests
- `ReconProvincesQuest` - Reconnoiter a number of provinces
- `ReconSpecificProvincesQuest` - Reconnoiter specific provinces
### Not Planned
### Economic Quests
- `TotalDevelopmentQuest` - Achieve total development level in a province
- `WealthQuest` - Accumulate gold and food
- `SpendOnFeastsQuest` - Spend gold on feasts
- `SendSuppliesQuest` - Send food to a specific province
- `RepairDevastationQuest` - Repair devastation
These quests are too situational, passive, or risky to actively pursue. They may be completed naturally through gameplay.
### Special Event Quests
- `SuppressRiotByForceQuest` - Suppress a riot by force
- `FightBeastsAloneQuest` - Fight beasts alone
- `StartBlizzardQuest` - Start a blizzard in a province
- `StartEpidemicQuest` - Start an epidemic in a province
- `ApprehendOutlawQuest` - Apprehend an outlaw hero
- `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.
### Miscellaneous
- `BattalionDiversityQuest` - Have diverse battalion types
- `SwearBrotherhoodWithHeroQuest` - Swear brotherhood with a specific hero
- `BetrayAllyQuest` - Betray an allied faction
## Implementation Details
@@ -148,34 +102,12 @@ Each chooser extends either:
- `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
1. Alliance
2. TruceWithFaction
3. Improve (Agriculture/Economy/Infrastructure)
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
-44
View File
@@ -1,44 +0,0 @@
load("@aspect_rules_js//js:defs.bzl", "js_binary", "js_run_binary")
load("@eagle0pedia_npm//:defs.bzl", "npm_link_all_packages")
package(default_visibility = ["//visibility:public"])
exports_files([
"package.json",
"pnpm-lock.yaml",
])
npm_link_all_packages()
filegroup(
name = "site_sources",
srcs = glob([
"src/**",
"astro.config.mjs",
"package.json",
"tsconfig.json",
]),
)
js_binary(
name = "astro",
data = [
":node_modules",
],
entry_point = "tools/astro-cli.mjs",
)
js_run_binary(
name = "site",
srcs = [
":node_modules",
":site_sources",
],
args = ["build"],
chdir = package_name(),
env = {
"ASTRO_TELEMETRY_DISABLED": "1",
},
out_dirs = ["dist"],
tool = ":astro",
)
-48
View File
@@ -1,48 +0,0 @@
# Eagle0pedia Site
Eagle0pedia is a Starlight static site. CI validates it through Bazel, while
DigitalOcean App Platform builds and hosts it directly as a static site.
## Local Build
```bash
bazel build //docs/eagle0pedia:site
```
The generated site is written to:
```text
bazel-bin/docs/eagle0pedia/dist
```
## Deployment
Eagle0pedia is hosted by DigitalOcean App Platform as a static site. Use these
component settings:
```text
Resource type: Static Site
Source directory: docs/eagle0pedia
Build command: corepack enable && pnpm install --frozen-lockfile && pnpm build
Output directory: dist
HTTP route: /
Domain: pedia.eagle0.net
```
The App Platform build uses the normal Starlight package scripts:
```text
npm run build
```
Set this App Platform environment variable:
```text
ASTRO_TELEMETRY_DISABLED=1
```
CI still validates the Bazel build:
```text
bazel build //docs/eagle0pedia:site
```
-56
View File
@@ -1,56 +0,0 @@
import { defineConfig } from "astro/config";
import starlight from "@astrojs/starlight";
export default defineConfig({
site: "https://pedia.eagle0.net",
integrations: [
starlight({
title: "Eagle0pedia",
description: "Reference guide for Eagle0 commands, systems, and game concepts.",
customCss: ["./src/styles/custom.css"],
editLink: {
baseUrl: "https://github.com/nolen777/eagle0/edit/main/docs/eagle0pedia/src/content/docs/",
},
sidebar: [
{
label: "Reference",
items: [
{ label: "Eagle0pedia", slug: "" },
{
label: "Concepts",
items: [
{ label: "Game Concepts", slug: "concepts" },
{ label: "Victory and Defeat", slug: "concepts/victory-and-defeat" },
{ label: "Who You Command", slug: "concepts/command-authority" },
{ label: "Eagle Round Flow", slug: "concepts/eagle-round-flow" },
{ label: "March and Battle Timeline", slug: "concepts/march-timeline" },
{ label: "Information and Recon", slug: "concepts/information-and-recon" },
{ label: "From Eagle to Shardok", slug: "concepts/eagle-to-shardok" },
{ label: "Shardok Battles", slug: "concepts/shardok-battles" },
{ label: "Shardok Combat Fundamentals", slug: "concepts/shardok-combat" },
{
label: "Shardok Victory Conditions",
slug: "concepts/shardok-victory-conditions",
},
],
},
{ label: "Eagle Commands", slug: "commands" },
{ label: "Shardok Commands", slug: "shardok-commands" },
{ label: "Heroes", slug: "heroes" },
{ label: "Battalions", slug: "battalions" },
{ label: "Factions", slug: "factions" },
{ label: "Provinces", slug: "provinces" },
{ label: "Integration Notes", slug: "integration" },
],
},
],
social: [
{
icon: "github",
label: "GitHub",
href: "https://github.com/nolen777/eagle0",
},
],
}),
],
});
-23
View File
@@ -1,23 +0,0 @@
{
"name": "eagle0pedia",
"version": "0.1.0",
"private": true,
"type": "module",
"packageManager": "pnpm@9.15.9",
"scripts": {
"build": "astro build",
"dev": "astro dev",
"preview": "astro preview"
},
"dependencies": {
"@astrojs/starlight": "^0.36.0",
"astro": "^5.11.0",
"sharp": "^0.34.0"
},
"devDependencies": {
"typescript": "^5.8.0"
},
"pnpm": {
"onlyBuiltDependencies": []
}
}
-4378
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -1,6 +0,0 @@
import { defineCollection } from "astro:content";
import { docsSchema } from "@astrojs/starlight/schema";
export const collections = {
docs: defineCollection({ schema: docsSchema() }),
};
@@ -1,74 +0,0 @@
---
title: Battalions
description: Troop count, type, training, armament, morale, heroes, and the battalion lifecycle.
---
Battalions are Eagle's organized troop formations and become units in [Shardok battles](/concepts/shardok-battles/) when attached to heroes.
## Reading a battalion
- **Troops** are its current soldiers. Casualties and starvation reduce this number; reaching zero destroys the battalion.
- **Type** determines movement, damage, battlefield limits, and whether an attached hero can cast or use stealth.
- **TRN (training)** increases the damage the battalion delivers. It is raised with [Train](/commands/#train).
- **ARM (armament)** reduces normal damage the battalion takes. It is raised with [Arm Troops](/commands/#arm-troops) and cannot exceed the province's effective infrastructure.
- **Morale** affects combat performance and gates many tactical actions. It changes through battle effects.
## How the combat factors work
These values do different jobs; they are not interchangeable ratings that simply add into one combat score.
- **Training** scales the battalion type's melee and missile damage bonuses. Higher training therefore means more damage per troop. It also affects some command-specific odds, including the chance to stun on a [Charge](/shardok-commands/#charge).
- **Armament** scales the battalion type's resistances and reduces incoming normal damage. Each type and damage category has its own resistance profile, so the same ARM value does not protect every battalion equally. Penetrating damage bypasses this normal armament reduction. If the unit can use [Archery](/shardok-commands/#archery), armament also determines its starting volleys: currently one volley per 10 ARM, rounded down, plus any bonus from its battalion type.
- **Morale** changes the battalion's damage swing. Most units also need at least 10 morale to use normal tactical commands.
- **Hero vigor** is an action constraint and current energy reserve, not battalion armor. It affects maximum AP and command access; at zero vigor, the hero-led unit is captured and removed from active battle. See [Vigor](/heroes/#vigor).
- **Troop count** multiplies battalion damage: more surviving troops deliver more total battalion damage. It also determines how many soldiers remain available to absorb casualties.
- **Battalion type** supplies the base damage, training bonus, resistance profile, armament benefit, movement costs, charge multiplier, and special capabilities.
- **Hero stats** contribute separately. Charisma and bravery affect battalion combat swings, while a hero also contributes personal damage and enables stat- or profession-based commands.
Exact enemy training, armament, hero stats, or other details may be hidden. Use only the information your faction has revealed; the game does not promise that an unknown value is zero.
## Battalion types
### Light Infantry
The basic flexible foot formation. It is inexpensive, has the largest normal infantry capacity, can carry casting heroes, and allows [Ranger](/heroes/#ranger) stealth. Its protection gains from ARM are useful but lower than those of heavy formations.
### Heavy Infantry
Slower and more expensive to train and equip, with fewer troops per full battalion. Heavy Infantry starts with substantial physical resistance and generally gains more protection from ARM than light formations. It also delivers heavier melee damage, but cannot cast or hide.
### Light Cavalry
Fast on open terrain and especially effective when charging: its charge deals 1.2 times normal unit damage. A charge spends only the terrain-entry cost, so Light Cavalry may move or use another available command afterward when it retains AP.
### Heavy Cavalry
The most expensive normal formation, with the smallest capacity and highest food cost. It combines strong built-in resistance and armament scaling with the largest charge multiplier, 1.5. Like Light Cavalry, it can sometimes act again after charging if AP remains.
### Longbowmen
A ranged specialist that is always [archery](/shardok-commands/#archery)-capable and receives two bonus volleys in addition to those provided by ARM. It can also support casting and stealth-capable heroes. Longbow attacks include penetrating missile damage. Because penetrating damage bypasses normal armament reduction, trained Longbowmen can inflict heavy casualties even on well-armed troops. They are comparatively poorly protected by their own ARM.
### Undead
An exceptional formation created through necromantic effects rather than ordinary recruitment. Undead have high capacity, do not consume food or use normal morale, can cross ocean terrain, and are vulnerable to [Holy Wave](/shardok-commands/#holy-wave). Their combat and movement rules differ enough that they should not be evaluated like a normal recruitable battalion.
## Heroes and units
A Shardok unit normally combines one hero and one battalion. The hero supplies stats, [vigor](/heroes/#vigor), profession commands, personal damage, and special capabilities; the battalion supplies troops, type, training, armament, movement, morale, and its damage profile. The hero's Charisma and Bravery also affect the battalion's combat swing. A profession alone may not be enough: casting requires a casting-capable battalion, and Ranger stealth requires a stealth-capable one.
The two parts remain one tactical unit. If the battalion reaches zero troops, it is destroyed. If the attached hero reaches zero vigor, the hero-led unit is captured and removed from active battle; the surviving battalion does not detach and continue fighting independently.
## Creating and changing battalions
[**Organize Troops**](/commands/#organize-troops) creates, resizes, merges, transfers, or dismisses battalions. New troops cost gold and begin without training or armament. Transfers preserve weighted training and armament. Empty battalions are removed.
Use [**Train**](/commands/#train) to improve eligible battalions in the province and [**Arm Troops**](/commands/#arm-troops) while visiting town to buy higher armament. Province economy and agriculture affect which types can be raised; effective infrastructure limits equipment.
## Movement, food, and battle
[March](/commands/#march) moves selected battalions with their heroes and supplies. Stationed and moving battalions consume food. A shortage spends the remaining food and can reduce battalions by up to 20 percent in one consumption event.
Battle results return surviving troop counts and statuses to Eagle. Battalions may survive with casualties, be destroyed, flee with their heroes, be captured/secured, or become the new garrison after conquest.
See [Organize Troops](/commands/#organize-troops), [Train](/commands/#train), [Arm Troops](/commands/#arm-troops), and [Shardok Battles](/concepts/shardok-battles/#units).
@@ -1,644 +0,0 @@
---
title: Eagle Commands
description: Strategic command reference for Eagle command panels.
---
Eagle commands are the strategic actions a faction can take from a province command panel. The server advertises available commands with `AvailableCommand`, and the client posts the matching `SelectedCommand`.
Most commands belong to the province currently acting this turn. Some are reaction commands, such as resolving diplomacy offers, handling captured heroes, or choosing a battle aftermath decision.
<span class="command-reference-marker" aria-hidden="true"></span>
<!--
Source map for numeric command details:
- Alms: src/main/resources/net/eagle0/eagle/settings.tsv fields almsSupportIncreasePerFood=0.01, almsPaladinSupportMultiplier=4, maxAlmsFood=1000; applied in src/main/scala/net/eagle0/eagle/library/actions/impl/command/AlmsCommand.scala and src/main/scala/net/eagle0/eagle/library/actions/availability/AvailableAlmsCommandFactory.scala.
- Feast: settings fields loyaltyGainFromFeast=10, vigorGainFromFeast=10, feastGoldCostPerHero=20; applied in src/main/scala/net/eagle0/eagle/library/actions/impl/command/FeastCommand.scala and src/main/scala/net/eagle0/eagle/library/actions/availability/AvailableFeastCommandFactory.scala.
- Hero Gift: settings fields loyaltyIncreasePerGold=0.25 and maxGiftGold=100; applied in src/main/scala/net/eagle0/eagle/library/actions/impl/command/HeroGiftCommand.scala and src/main/scala/net/eagle0/eagle/library/actions/availability/AvailableHeroGiftCommandFactory.scala.
- Rest: settings field restVigorGain=15; applied in src/main/scala/net/eagle0/eagle/library/actions/impl/command/RestCommand.scala.
- Vigor costs: src/main/resources/net/eagle0/eagle/settings.tsv fields actionVigorCost=15, reconVigorCost=30, apprehendOutlawVigorCost=30, controlWeatherVigorDelta=-40, startEpidemicVigorDelta=-30, vigorGainFromFeast=10, restVigorGain=15.
- Divine: divinable heroes are unaffiliated heroes with RecruitmentInfo.NotDivined; see src/main/scala/net/eagle0/eagle/library/actions/availability/AvailableDivineCommandsFactory.scala and src/main/scala/net/eagle0/eagle/library/actions/impl/command/DivineCommand.scala.
- Issue Orders: order availability comes from src/main/scala/net/eagle0/eagle/library/actions/availability/AvailableIssueOrdersCommandFactory.scala; vassal order behavior comes from src/main/scala/net/eagle0/eagle/library/actions/impl/action/PerformVassalCommandsPhaseAction.scala, src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/CommandChoiceHelpers.scala, and src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/ExpandCommandSelector.scala.
- Send Supplies: shipment scheduling comes from src/main/scala/net/eagle0/eagle/library/actions/impl/command/SendSuppliesCommand.scala; arrival comes from src/main/scala/net/eagle0/eagle/library/actions/impl/action/PerformProvinceMoveResolutionAction.scala and src/main/scala/net/eagle0/eagle/library/actions/impl/action/ShipmentArrivedAction.scala; timing/loss settings are src/main/resources/net/eagle0/eagle/settings.tsv fields turnsToResolveShipment=1 and shipSuppliesLoss=0.1.
- Battle Aftermath Decision: availability comes from src/main/scala/net/eagle0/eagle/library/actions/availability/AvailableBattleAftermathDecisionCommandFactory.scala; selection comes from src/main/scala/net/eagle0/eagle/library/actions/impl/command/BattleAftermathDecisionCommand.scala; finalization comes from src/main/scala/net/eagle0/eagle/library/actions/impl/action/FinalizeAftermathAction.scala.
- Manage Prisoners: availability comes from src/main/scala/net/eagle0/eagle/library/actions/availability/AvailableManagePrisonersCommandFactory.scala; option effects come from src/main/scala/net/eagle0/eagle/library/actions/impl/command/ManagePrisonersCommand.scala; prisoner move/return timing comes from src/main/scala/net/eagle0/eagle/library/actions/impl/action/EndPlayerCommandsPhaseAction.scala; settings include prisonerEscapeChance=0.01.
- Handle Captured Hero: availability comes from src/main/scala/net/eagle0/eagle/library/actions/availability/AvailableHandleCapturedHeroCommandFactory.scala; option effects come from src/main/scala/net/eagle0/eagle/library/actions/impl/command/HandleCapturedHeroesCommand.scala; deferred aftermath effects come from src/main/scala/net/eagle0/eagle/library/actions/impl/action/EndBattleAftermathPhaseAction.scala; settings include startingLoyalty=60 and truceMonthsFromReturningLeader=12.
-->
## Normal Command Phase Commands
Most command phase actions spend the acting province's turn.
### Alms
<b>Hero requirements:</b> None. [Paladins](/heroes/#paladin) get a bonus.
<b>Vigor cost:</b> 15.
<b>Available when:</b> the acting province has food to give and an eligible hero with enough vigor.
<b>Effect:</b> Give up to 1000 food from the acting province as charity. The province gains 1 [support](/provinces/#support) per 100 food, capped by the province's maximum 100 support. Paladins multiply the support gain by 4, so a Paladin gives 4 support per 100 food. The acting hero spends vigor and gains charisma XP.
<b>Selection:</b> choose an amount of food and the acting hero.
### Apprehend Outlaw
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> 30.
<b>Available when:</b> an [outlaw hero](/factions/#free-heroes) is present in the acting province and an eligible hero has enough vigor.
<b>Effect:</b> Attempt to capture the outlaw. On success, the outlaw is removed from the province's unaffiliated heroes and re-added as a prisoner with a bias against the imprisoning faction. The acting hero spends vigor, and the game creates a deferred notification/story request.
<b>Selection:</b> choose the outlaw, an optional battalion, and the acting hero.
### Control Weather
<b>Hero requirements:</b> [Mage](/heroes/#mage).
<b>Vigor cost:</b> 40.
<b>Available when:</b> a Mage has enough vigor and the acting province or an adjacent province has a valid weather change.
<b>Effect:</b> Change weather in a target province. Available options can start or end blizzards and droughts. The target province receives a deferred weather change, and the acting hero records a weather backstory event, spends or gains the configured vigor delta, and gains wisdom XP.
<b>Selection:</b> choose the weather control type, target province, and acting hero.
### Diplomacy
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> 15 from the messenger hero.
<b>Available when:</b> a diplomatic option is valid against another faction and an eligible messenger hero can be sent.
<b>Effect:</b> Send a hero to another faction with a diplomatic proposal. The target faction receives the offer at the end of the current round, after any battles this round have taken place. Truce, invitation, alliance, break-alliance, and ransom options create [incoming diplomacy offers](/factions/#incoming-diplomacy-offers) for the target faction and story-generation requests. Most diplomacy options spend gold and cost the messenger vigor.
For truce, invitation, alliance, and break-alliance missions, the envoy leaves the acting province immediately and is unavailable for the rest of the current round, including its battles. The envoy normally returns during **Diplomacy Resolution** near the end of that same round and can act again next round. If the origin province has been lost, the envoy returns elsewhere in the faction when possible. An imprisoned envoy does not return.
<b>Selection:</b> choose the diplomacy option, target faction, and sent hero.
### Exile Vassal
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> None.
<b>Available when:</b> this province has at least one [vassal](/heroes/#vassals).
<b>Effect:</b> Remove the selected vassal from your faction.
<b>Selection:</b> choose the hero to exile.
### Feast
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> None. This restores up to 10 vigor for each ruling-faction hero in the province.
<b>Gold cost:</b> 20 gold for each ruling-faction hero in the province, multiplied by the province [price index](/provinces/#price-index) and rounded up. For example, a province with 3 ruling-faction heroes and a 1.25 price index pays 75 gold.
<b>Available when:</b> the acting province can pay the full feast cost.
<b>Effect:</b> Hold a feast in the acting province. Every ruling-faction hero in the province gains up to 10 loyalty, capped at 100, and up to 10 vigor, capped by constitution. Faction leaders do not gain loyalty, but can still gain vigor. The province spends gold, the price index may change, and feast-spending quest counters can advance.
<b>Selection:</b> no additional choices.
### Hero Gift
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> None.
<b>Available when:</b> the province has a non-leader hero with less than 100 LOY and gold available to give.
<b>Effect:</b> Send up to 100 gold as a gift to the selected hero. Loyalty gain depends on the gold amount, the recipient province's price index, and the hero's current loyalty: the base gain is 0.25 loyalty per gold, divided by price index, capped by the remaining room to 100 LOY.
<b>Selection:</b> choose the recipient hero and amount.
### Improve
<b>Hero requirements:</b> None. [Engineers](/heroes/#engineer) get a bonus.
<b>Vigor cost:</b> 15.
<b>Available when:</b> a hero has enough vigor and the province has an available improvement or devastation repair option.
<b>Effect:</b> Improve a province using one of the available improvement types. [Economy, agriculture, and infrastructure](/provinces/#development-stats) rise toward 100; [devastation](/provinces/#devastation) repair reduces all three devastation tracks. The acting hero spends vigor and gains strength/agility XP, and the improvement can optionally be locked as the province's preferred type.
<b>Selection:</b> choose the improvement type, acting hero, and whether to lock the type.
### Issue Orders
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> None. This does not take your turn.
<b>Available when:</b> a faction leader is acting and your faction controls more than one province. This command does not take your turn.
<b>Effect:</b> Set [standing orders](/provinces/#province-orders) for your provinces. Orders guide vassal-ruled provinces during the [Vassal Commands phase](/concepts/eagle-round-flow/#vassal-commands); provinces led directly by faction leaders are still commanded directly. Vassals may still handle urgent problems first, such as defense, riots, severe fatigue, low support, beast suppression, loyalty management, or other immediately available crisis commands.
<b>Selection:</b> submit the new province orders and the new focus province, or clear the focus province.
#### Order Meanings
##### Entrust
Use the vassal's judgment. If the province borders an enemy, the vassal tends to act like **Mobilize**. Otherwise, the vassal tends to act like **Develop**.
##### Develop
Build up the province. The vassal may send away surplus gold and food toward your focus province, if one is set, or otherwise toward the nearest faction leader. Then it prefers **Improve** actions that raise economy, agriculture, infrastructure, or repair devastation. If nothing useful is available, the province rests.
##### Mobilize
Prepare for war. The vassal may ship excess gold and food toward your focus province, if one is set, or otherwise toward the nearest faction leader. Then it may organize troops, arm troops, improve infrastructure when it limits training, train battalions, and fall back to improvement or rest.
##### Expand
Look for growth or attack opportunities. The vassal first tries to march into neutral provinces, usually leaving enough heroes behind to keep the origin province held. If expansion is not practical, it may spread heroes among your existing provinces, attack enemies when appropriate, improve the province, or rest.
### March
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> 15 from each marching hero.
<b>Available when:</b> the acting province has movable heroes, battalions, gold, or food and at least one valid destination.
<b>Effect:</b> Move heroes, battalions, gold, and food from one province to another. March is inter-province movement and always has a destination; [Visit Town](#visit-town) is only a local visit within the current province. The selected units and supplies leave the origin province immediately, each marching hero spends vigor and gains wisdom XP, and the destination receives an [incoming army](/provinces/#incoming-armies) scheduled for the next round.
If the destination is already ruled by your faction when the army arrives, the heroes, battalions, gold, and food enter the province during the next round's Province Move Resolution phase, before riots, vassal commands, and player commands.
If the destination is neutral or hostile, the army does not simply appear in the province at Province Move Resolution. It is set up as a hostile army later in that same next round, after player commands. A neutral province can then be taken in the Uncontested Conquest phase; a defended province can lead into attack, defense, battle request, and battle resolution phases. Until one of those outcomes resolves, the marching heroes are still in the moving army rather than ruling the destination.
<!-- Sources:
- March scheduling: src/main/scala/net/eagle0/eagle/library/actions/impl/command/MarchCommand.scala
- Friendly arrival timing: src/main/scala/net/eagle0/eagle/library/actions/impl/action/PerformProvinceMoveResolutionAction.scala
- Hostile setup and neutral conquest timing:
src/main/scala/net/eagle0/eagle/library/actions/impl/action/PerformHostileArmySetupAction.scala
src/main/scala/net/eagle0/eagle/library/actions/impl/action/PerformUncontestedConquestAction.scala
- Phase order: src/main/scala/net/eagle0/eagle/model/state/RoundPhase.scala
-->
<b>Selection:</b> choose origin, destination, gold, food, and marching units.
### Organize Troops
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> None.
<b>Available when:</b> the acting province has battalions to manage, troops to reorganize, or gold to raise new troops.
<b>Effect:</b> Create, resize, merge, transfer, or dismiss battalions in the acting province. New troops cost gold and enter with no training or armament, while transferred troops preserve weighted training and armament. Empty battalions are destroyed, new battalions get generated names and initial backstory requests, and the province price index may change from the gold spend.
<b>Selection:</b> submit changed battalions and new battalions, including troop movements and new troop counts.
### Recon
<b>Hero requirements:</b> [Ranger](/heroes/#ranger).
<b>Vigor cost:</b> 30.
<b>Available when:</b> a Ranger has enough vigor and there is a valid target province to scout.
<b>Effect:</b> Send a hero to scout another province.
<b>Selection:</b> choose the acting hero and target province.
### Rest
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> None. This restores up to 15 vigor for each ruling-faction hero in the province.
<b>Available when:</b> the acting province can spend its turn resting.
<b>Effect:</b> Have the acting province rest for the turn. Faction heroes in the province regain up to 15 vigor, capped by constitution, and rest-related quest counters can advance.
<b>Selection:</b> no additional choices.
### Send Supplies
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> 15.
<b>Available when:</b> the acting province has supplies to send, an eligible hero, and a valid destination province.
<b>Effect:</b> Send gold and food from the acting province to another province. The origin province loses the full selected amount immediately, and the destination receives an [incoming shipment](/provinces/#incoming-shipments) scheduled for the next round.
Shipments arrive during the next round's Province Move Resolution phase, before riots, hero departures, vassal commands, player commands, battles, and food consumption. That means food shipped this way is available to feed the destination province later in the same round it arrives. When the shipment arrives, 10% is lost in transit and the destination receives the remaining gold and food, rounded down.
<b>Selection:</b> choose gold, food, acting hero, and destination province.
### Start Epidemic
<b>Hero requirements:</b> [Necromancer](/heroes/#necromancer).
<b>Vigor cost:</b> 30.
<b>Available when:</b> a Necromancer has enough vigor and the acting province or an adjacent province can receive an epidemic.
<b>Effect:</b> Start an epidemic in the target province.
<b>Selection:</b> choose the target province and acting hero.
### Suppress Beasts
<b>Hero requirements:</b> None. Heroes with any profession get a power bonus.
<b>Vigor cost:</b> 15 on success, plus any vigor lost to casualties. If the hero is killed, the command fails.
<b>Available when:</b> the acting province has a [beast event](/provinces/#province-events) and an eligible hero.
<b>Effect:</b> Send a hero, optionally with a battalion, to suppress beasts in the acting province. The fight rolls beast power, applies casualties to the battalion and/or hero, removes the beast event on success or failure, and creates a story notification. Success can award gold, food, support, hero XP, and battalion backstory; failure can kill the hero and destroy the battalion.
<b>Selection:</b> choose the hero and optional battalion.
### Swear Kinship
<b>Hero requirements:</b> The target hero must have at least 95 loyalty.
<b>Vigor cost:</b> None.
<b>Available when:</b> the faction head is in the province, your faction has fewer than 5 faction leaders, and another hero in the province has at least 95 loyalty.
<b>Effect:</b> Choose a hero for a kinship bond. A hero who swears kinship becomes a [faction leader](/heroes/#faction-leaders).
<b>Selection:</b> choose the hero to swear kinship with.
### Train
<b>Hero requirements:</b> None. [Champions](/heroes/#champion) get a bonus.
<b>Vigor cost:</b> 15.
<b>Available when:</b> a hero has enough vigor and the province has at least one battalion with less than 100 TRN.
<b>Effect:</b> Raise the TRN of all trainable battalions in the province. The more troops are present, the lower the amount raised. The acting hero spends vigor and gains charisma, strength, and agility XP.
<b>Selection:</b> choose the acting hero.
### Visit Town
<b>Hero requirements:</b> None. The acting province's ruling hero makes the visit.
<b>Vigor cost:</b> 15.
<b>Available when:</b> the acting province's ruling hero has at least 40 vigor, the hero is not already visiting town, and there is no blizzard.
<b>Effect:</b> Have the ruling hero leave camp to visit the town inside the acting province. Visit Town does not move any hero, battalion, or supplies to another province, so it has no destination; use [March](#march) for inter-province movement.
Visit Town spends the ruling hero's vigor and unlocks whichever town commands below are currently eligible. You can use as many eligible town commands as you want in the same round before using Return to go back to camp. If no town activity currently qualifies, Return may be the only command available.
<b>Selection:</b> no additional choices. Visit Town always uses the current province and never asks you to select a destination.
## Town Commands
You must use Visit Town before these local commands appear. The ruling hero remains in the current province; only the available command set changes from camp duties to town activities. While visiting town, you can use as many eligible town commands as you want in one round before using Return to come back to camp.
### Arm Troops
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> None.
<b>Available when:</b> the ruling hero is visiting town, the province has gold, and at least one battalion can raise ARM without exceeding effective infrastructure.
<b>Effect:</b> Spend gold to raise armament on existing battalions. New armament must be higher than the battalion's current armament and cannot exceed the province's effective infrastructure. The province spends gold, the price index may change, and each armed battalion records a backstory event.
<b>Selection:</b> choose the battalions to arm and how much armament each receives.
### Decline Quest
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> None.
<b>Available when:</b> the ruling hero is visiting town and an [unaffiliated hero](/heroes/#unaffiliated-heroes) quest is available in the province.
<b>Effect:</b> Decline one of the unaffiliated hero quests available in the acting province.
<b>Selection:</b> choose the hero whose quest should be declined.
### Divine
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> None.
<b>Gold cost:</b> 100 gold per selected hero, multiplied by the province [price index](/provinces/#price-index) and rounded to the nearest whole gold for each hero. The total cost is that per-hero cost multiplied by the number of selected heroes. For example, divining 3 heroes at a 1.25 price index costs 375 gold.
<b>Available when:</b> the ruling hero is visiting town, the province can pay the cost for at least one hero, and there are free heroes who are not yet ready to join your faction but might be won over.
<b>Effect:</b> Spend gold to divine one or more [free heroes](/factions/#free-heroes). For each divined hero, you receive details about what could impress them enough to become recruitable. Fulfill that ask, and the hero can later appear under Recruit Heroes. The province spends gold, and the price index may change.
<b>Selection:</b> choose the heroes to divine.
### Manage Prisoners
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> None.
<b>Available when:</b> the ruling hero is visiting town and the province has prisoner heroes with available management options.
<b>Effect:</b> Choose what happens to ordinary prisoners in the province. These are heroes who are already in the prisoner state, not newly captured heroes still waiting for a battle aftermath decision.
<b>Selection:</b> choose the prisoner and management option.
#### Options
##### Execute
<b>Effect:</b> Kill the prisoner and remove them from the game.
##### Exile
<b>Available when:</b> the prisoner is not a faction leader.
<b>Effect:</b> Turn the prisoner into an outlaw in the same province.
##### Release
<b>Available when:</b> the prisoner is not a faction leader.
<b>Effect:</b> Release the prisoner as a traveler in the same province. Released prisoners are no longer in your custody, but they do not automatically join another faction.
##### Move
<b>Available when:</b> you control an adjacent province.
<b>Effect:</b> Send the prisoner to one adjacent province ruled by your faction. The prisoner leaves the origin province now and arrives at the destination at the end of the Player Commands phase. There is a 1% chance the prisoner escapes during the move; if that happens, they become an outlaw in the destination province instead of remaining a prisoner.
##### Return
<b>Available when:</b> the prisoner is a faction leader and their faction still controls at least one province.
<b>Effect:</b> Return the prisoner to their faction. They leave your custody now and rejoin their faction at the end of the Player Commands phase as a ruling hero in one of that faction's provinces.
Ordinary prisoners cannot be returned this way. Faction leader prisoners cannot be released or exiled, but they can be executed, moved within your territory, or returned if their faction still exists.
### Recruit Heroes
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> None.
<b>Available when:</b> the province is ruled by one of the acting faction's faction leaders, that ruling faction leader is visiting town, and at least one unaffiliated hero in the province currently qualifies to join that faction. The visitor may be any faction leader; it does not have to be the faction head. Vassals cannot use this command.
<b>Effect:</b> Recruit available unaffiliated heroes in the acting province. Recruited heroes join the faction at starting loyalty, move from the province's unaffiliated hero list into its ruling faction heroes, and receive a recruitment backstory event.
<b>Selection:</b> choose the heroes to recruit.
See [**What `WOULD_JOIN` Means**](/factions/#what-would_join-means) for the recruitment status, traveler exception, and separate **Please Recruit Me** behavior in vassal-ruled provinces.
### Return
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> None.
<b>Available when:</b> the ruling hero is visiting town.
<b>Effect:</b> Return the ruling hero from the current province's town to camp and end that province's turn.
<b>Selection:</b> no additional choices.
### Trade
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> None.
<b>Available when:</b> the ruling hero is visiting town and the province has enough gold or food for at least one trade.
<b>Effect:</b> Buy or sell food using the acting province's gold and food stores. Food buy and sell prices are based on the province price index. Buying spends gold and adds food; selling removes food and adds gold. The price index may change after the trade.
<b>Selection:</b> choose buy or sell and the amount of food.
## Special and Conditional Commands
These commands appear only when the game is resolving a special situation, response, or pending decision.
### Attack Decision
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> None.
<b>Available when:</b> an attack decision is pending.
<b>Effect:</b> Choose how to respond to a pending attack opportunity or battle decision. The command shows involved armies, acting units, and the available attack decision types. **Advance** commits the army to the attack. **Withdraw** sends it back without fighting. **Demand Tribute** lets you request a specific amount of the province's gold and food, up to the displayed maximum; the demand cannot be zero. The defender later chooses whether to pay the full demand or refuse it. See [Attack and Defense Decisions](/concepts/eagle-to-shardok/#attack-and-defense-decisions) and [Resolving a tribute demand](/concepts/march-timeline/#resolving-a-tribute-demand).
<b>Selection:</b> choose one available attack decision.
### Defend
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> None.
<b>Available when:</b> a province is under attack and can commit defenders.
<b>Effect:</b> Respond to an incoming attack by committing a defending army. The selected defenders become the province's defending army. If flee provinces are available, one must be selected as the army's fallback destination. See [Attack and Defense Decisions](/concepts/eagle-to-shardok/#attack-and-defense-decisions) for what happens next.
<b>Selection:</b> choose defending units and, when available, an optional flee province.
### Handle Captured Hero
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> None.
<b>Available when:</b> a newly captured hero needs a battle aftermath decision.
<b>Effect:</b> Decide what happens to a hero captured during [battle aftermath](/concepts/eagle-to-shardok/#aftermath) or conquest. Captured heroes are not yet ordinary prisoners. If you imprison one, they become a normal prisoner and can later be handled with Manage Prisoners.
<b>Selection:</b> choose the captured hero and the handling option.
#### Options
##### Recruit
<b>Available when:</b> the captured hero is not a faction leader, has not already refused recruitment, and is not someone who will never join your faction.
<b>Effect:</b> Try to persuade the captured hero to join you immediately. The chance depends on your faction head, your faction's prestige, and the captured hero. On success, the hero joins your faction in the captured province with 60 loyalty. On failure, the hero refuses; you cannot try to recruit that captured hero again from this aftermath decision and must choose another option.
##### Imprison
<b>Effect:</b> Keep the hero as a prisoner in the captured province. This converts them into an ordinary prisoner when the aftermath resolves.
##### Exile
<b>Available when:</b> the captured hero is not a faction leader whose faction still controls a province.
<b>Effect:</b> Cast the hero out as an outlaw in the captured province when the aftermath resolves.
##### Execute
<b>Effect:</b> Kill the captured hero and remove them from the game when the aftermath resolves.
##### Return
<b>Available when:</b> the captured hero is a faction leader and their faction still controls at least one province.
<b>Effect:</b> Return the captured faction leader to the closest province ruled by their old faction when the aftermath resolves. Returning a captured leader creates a 12-month truce between your faction and theirs.
### Handle Riot
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> Depends on the option. Crack Down can cost vigor; Give and Do Nothing do not.
<b>Available when:</b> a province has a riot.
<b>Effect:</b> Respond to a [riot](/provinces/#province-events) in the acting province. The client presents this as one command with multiple response options. The [province leader](/provinces/#ruling-faction-and-ruling-hero)'s Charisma matters, even if a different hero is selected to crack down: leader Charisma improves gift success and makes forceful crackdowns face smaller riots.
If you fail to stop the riot, or if the riot is still unresolved when the riot phase ends, the riot happens. The province loses 25 support, gains 25 economy devastation, gains 25 infrastructure devastation, and the riot event is removed.
<!-- Sources:
- Riot options: src/main/scala/net/eagle0/eagle/library/actions/impl/command/HandleRiotGiveCommand.scala
src/main/scala/net/eagle0/eagle/library/actions/impl/command/HandleRiotCrackDownCommand.scala
src/main/scala/net/eagle0/eagle/library/actions/impl/command/HandleRiotDoNothingCommand.scala
- Riot consequences: src/main/scala/net/eagle0/eagle/library/actions/impl/command/HandleRiotUtils.scala
- Riot settings: src/main/scala/net/eagle0/eagle/library/settings/BUILD.bazel
-->
#### Options
##### Crack Down
<b>Vigor cost:</b> 15 on success, plus any vigor lost to casualties. If the hero is killed, the command fails.
<b>Effect:</b> Try to suppress the riot by force using a hero and optionally a battalion. The riot size is random from 100 to 1500, but the province leader's Charisma, not the selected acting hero's Charisma, reduces the roll before the size is calculated. Higher leader Charisma therefore makes the riot smaller and easier to defeat.
Casualties are based on riot size against the power of the acting hero and any attached battalion. If a battalion is used, casualties hit the battalion first; the battalion can be destroyed, and excess casualties can injure or kill the acting hero. If no battalion is used, casualties hit the acting hero directly.
Crack Down succeeds if the acting hero survives. On success, the riot event is removed and the acting hero spends 15 vigor, gains Strength and Agility XP, and may take additional vigor damage from casualties. If a battalion helped and survived, its size is reduced by its casualties. A successful crackdown does not directly reduce support, add devastation, or change loyalty; the cost is the violence itself.
Crack Down fails if the acting hero is killed. A failed crackdown does not stop the riot; the riot will still occur and apply the normal riot damage.
<b>Selection:</b> choose the acting hero and an optional battalion.
##### Do Nothing
<b>Vigor cost:</b> None.
<b>Effect:</b> Let the riot proceed without intervention. The riot will occur, causing the normal riot damage: -25 support, +25 economy devastation, and +25 infrastructure devastation.
<b>Selection:</b> no additional choices.
##### Give
<b>Vigor cost:</b> None.
<b>Effect:</b> Offer either food or gold to address the riot. You cannot combine food and gold in the same response. The maximum useful gift is 1000 food or 500 gold, and the selected amount is spent whether the attempt succeeds or fails.
Gift success is probabilistic. The chance is based on the province leader's Charisma plus the fraction of the maximum gift you gave: larger gifts help, and more leader Charisma helps. A partial gift can fail. On success, the riot event is removed. On failure, the gift is still spent and the riot will still occur.
<b>Selection:</b> choose a food amount or a gold amount.
### Please Recruit Me
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> None.
<b>Available when:</b> unaffiliated heroes ask to join.
<b>Effect:</b> Respond to unaffiliated heroes who are asking to join a province.
<b>Selection:</b> choose the province, hero, and whether to accept.
### Resolve Alliance Offer
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> None.
<b>Available when:</b> another faction offers an alliance.
<b>Effect:</b> Accept or reject an [incoming alliance offer](/factions/#incoming-diplomacy-offers).
<b>Selection:</b> choose the originating faction and the resolution.
### Resolve Break Alliance
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> None.
<b>Available when:</b> another faction tries to break an alliance.
<b>Effect:</b> Respond to a faction attempting to break an alliance. Accepting the break replaces the alliance with a 12-month truce; it does not make the factions immediately hostile. The truce remains active through its reset month and changes to **Hostile** when a later **New Round** advances the date past that month. For example, a break accepted in January has a January reset date the following year and becomes hostile when February begins.
Imprisoning the envoy ends the alliance without a truce and makes both relationships **Hostile**. See [Diplomacy Outcomes](/factions/#diplomacy-outcomes) for the other imprisonment consequences.
<b>Selection:</b> choose the originating faction and the resolution.
### Resolve Invitation
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> None.
<b>Available when:</b> another faction sends an invitation.
<b>Effect:</b> Accept or reject an invitation from another faction.
<b>Selection:</b> choose the originating faction and the resolution.
### Resolve Ransom Offer
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> None.
<b>Available when:</b> another faction makes a ransom offer.
<b>Effect:</b> Accept or reject an incoming ransom offer.
<b>Selection:</b> choose the offering faction, the ransom offer, and the resolution.
### Resolve Truce Offer
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> None.
<b>Available when:</b> another faction offers a truce.
<b>Effect:</b> Accept or reject an incoming truce offer.
<b>Selection:</b> choose the originating faction and the resolution.
### Resolve Tribute
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> None.
<b>Available when:</b> another faction demands tribute.
<b>Effect:</b> Decide whether to pay demanded tribute. The command shows the demanding factions, requested tribute, their rough strength, and the acting province's available food and gold. A demand must be accepted or refused in full; partial payment and counteroffers are not available. Paying requires the province to have the full requested amount, creates a 12-month truce between the factions, and turns back the demanding faction's hostile armies against the payer's provinces. Refusing transfers no resources and returns that faction's army to **Attacking**, after which normal defense selection and battle resolution continue. Neither response changes prestige directly, although a battle caused by refusal can later affect prestige. See [Resolving a tribute demand](/concepts/march-timeline/#resolving-a-tribute-demand).
<b>Selection:</b> choose the demanding faction and whether tribute is paid.
### Battle Aftermath Decision
<b>Hero requirements:</b> None.
<b>Vigor cost:</b> None.
<b>Available when:</b> multiple allied attacking factions won the same province and Eagle needs to decide which claimant keeps it.
<b>Effect:</b> Decide what your surviving claimant army does during [battle aftermath](/concepts/eagle-to-shardok/#aftermath) after a shared victory. If no ally has claimed the province yet, you may choose **Keep Province** and become the conqueror. If an ally has already claimed it, you must withdraw.
Keeping the province finalizes conquest: your surviving non-fled heroes become the new ruling heroes, surviving battalions become the province battalions, support is reset to 0, province orders reset to **Entrust**, and non-fled defenders or former ruling heroes can become prisoners.
Withdrawing sends your surviving units to an adjacent province as a moving army. You may take up to the gold and food your claimant army brought into the battle. The withdrawal army arrives next round and does not use the normal Send Supplies transit loss.
<b>Selection:</b> choose whether to keep the province or withdraw. If withdrawing, choose destination province and supplies to take.
@@ -1,62 +0,0 @@
---
title: Who You Command
description: Faction leaders, vassals, province turns, and direct control.
---
Eagle commands are issued through provinces, but who chooses a province's action depends on its ruling hero. The key distinction is between **faction leaders**, whom the player commands directly, and **vassals**, who govern automatically.
## Leadership roles
| Role | Directly commanded? | Main purpose |
| --- | --- | --- |
| Faction head | Yes | Defines the faction and provides its original leadership |
| Other faction leader | Yes | Gives the player another directly controlled leader and a successor |
| Vassal | No | Rules a province according to standing orders and local needs |
| Unaffiliated hero | No | May be recruited, divined, apprehended, or otherwise interacted with |
The faction head is also a faction leader. **Swear Kinship** can promote a loyal faction hero into an additional faction leader. A faction leader who rules a province lets the player choose that province's normal command directly.
## Province turns
Most strategic actions belong to the province currently acting. During **Player Commands**, provinces led by faction leaders present their available commands to the player. A normal command such as Train, Improve, March, or Rest spends that province's turn.
The hero selected for a command is its actor, but selecting a different hero does not create another province turn. Several heroes may be eligible to perform the same province action; the player chooses which hero pays vigor and receives the associated experience.
Some commands explicitly do not take the turn. For example, **Issue Orders** changes standing instructions without consuming the acting province's normal action. The command reference labels these exceptions.
## Vassal-led provinces
A vassal-led province acts during **Vassal Commands**, before the main Player Commands phase. The vassal chooses an action using:
- the province's standing order: Entrust, Develop, Mobilize, or Expand;
- the faction's focus province;
- urgent local needs such as defense, riots, fatigue, low support, or beasts; and
- the commands actually available in the province.
Standing orders guide a vassal; they do not guarantee one exact command. A vassal may address a crisis before following a long-term development or expansion preference.
## Multiple leaders and provinces
Gaining more faction leaders does not give one province several normal actions. It expands direct control:
- A leader can directly rule a province instead of leaving it to a vassal.
- Leaders in different provinces can make those provinces player-controlled.
- Leaders provide succession if the faction head dies.
Heroes who are present but do not rule the province can still be selected for eligible commands. Province leadership determines who chooses the action, not which local hero must perform it.
## A simple example
Suppose your faction controls three provinces:
1. Your faction head rules the capital. You choose its action during Player Commands.
2. A second faction leader rules the frontier. You choose that province's action too.
3. A vassal rules a farming province with Develop orders. The vassal chooses its own action during Vassal Commands.
The result is two direct player decisions and one automatic vassal decision, assuming no riot, diplomacy offer, battle decision, or other special phase adds another required choice.
## Special decisions
Attack decisions, defense decisions, diplomacy responses, prisoner handling, riots, and battle aftermath are conditional decisions rather than extra normal province turns. They appear only when the current game state requires them and can pause the shared phase until every required faction responds.
See [Eagle Round Flow](/concepts/eagle-round-flow/) for phase timing and [Eagle Commands](/commands/) for which actions consume a turn.
@@ -1,189 +0,0 @@
---
title: Eagle Round Flow
description: How Eagle strategic rounds advance, including simultaneous player action.
---
<!--
Source map:
- Phase enum: src/main/protobuf/net/eagle0/eagle/common/round_phase.proto.
- Phase advancement: src/main/scala/net/eagle0/eagle/library/RoundPhaseAdvancer.scala.
- Command availability by phase: src/main/scala/net/eagle0/eagle/library/actions/availability/AvailableCommandsFactory.scala.
- Result visibility/filtering: src/main/scala/net/eagle0/eagle/library/ActionResultFilter.scala and src/main/scala/net/eagle0/eagle/library/util/view_filters/.
-->
Eagle is a simultaneous-turn strategy game. During a phase, every faction gets the chance to act in parallel. The game does not fully resolve the phase just because one player chooses a command; it waits until every required player choice has been made, then advances the phase and reveals the results each faction is allowed to know.
That means two things matter:
- Your orders are not a private single-player timeline. Other factions may be choosing their own commands at the same time.
- You do not automatically see every action in the world. You see your faction's results, your allies' visible results, and information your faction has earned through scouting, battle revelations, diplomacy, or province control.
## Round Shape
Most rounds follow this broad rhythm:
1. Upkeep and automatic world changes happen.
2. Vassals and players issue strategic commands.
3. Marching armies arrive, attacks are decided, and battles are requested.
4. Shardok battles resolve outside the strategic map.
5. Eagle applies battle results, aftermath choices, diplomacy, recon, and end-of-round changes.
Some phases are player-facing. Others are bookkeeping phases that advance automatically unless a special decision is needed.
## One Round Is One Month
The strategic date advances once per complete Eagle round, not once per command. A
command submitted during Player Commands belongs to the current month's round. The
server then continues through the remaining phases and starts the next round, whose
New Round processing advances the date by one month.
In a one-player game, the automatic phases may finish so quickly that it looks as
though the command itself advanced the month. If a decision or battle is pending,
the game remains in the same round and month until that work is complete.
## Phase Diagram
This diagram groups the detailed phases into their player-visible states:
```text
New Round and upkeep
|
v
Province movement and events
|
+----> Riot or recruitment decision? ----> wait for commands
|
v
Vassal Commands -> Player Commands --------------> wait for every required faction
|
v
Conquest setup -> Attack Decision -> Defense Decision
|
v
Battle Request and food use
|
v
Battle Resolution ----------> wait for all Shardok battles
|
v
Battle Aftermath -----------> wait for pending choices
|
v
Diplomacy Resolution -------> wait for pending replies
|
v
Recon Resolution
|
+-----------------> next month's New Round
```
"Wait" means the phase does not advance yet. The server may be waiting for your
choice, another faction's choice, or an external Shardok battle result.
## Automatic Upkeep
These phases usually advance without direct player input:
### New Round
The game starts a new strategic round. Provinces, heroes, factions, and long-running state are updated for the new date.
### Prisoner Exchange
The game resolves automatic prisoner exchange state.
### Province Events
Province events such as local problems or opportunities are processed.
### Forced Turn Back
Armies that must turn back do so before normal movement resolution.
### Province Move Resolution
Moving armies arrive at their destinations, supplies move, and incoming hostile groups are created where armies meet resistance.
### Hero Departures
The game checks affiliated heroes for [low-loyalty departure](/heroes/#loyalty-departure). Any hero with loyalty below 50 may leave at any time, and lower loyalty means a greater risk. A departing hero becomes an unaffiliated traveler in the same province and does not take the province's supplies.
### Unaffiliated Hero Actions
Unaffiliated heroes may move, appear, escape, or otherwise act without belonging to a faction.
### Hostile Army Setup
The game prepares hostile army groups before conquest or battle decisions.
### Uncontested Conquest
If an attacker reaches a province with no defending force, the conquest can resolve without a Shardok battle.
### Truce Turn Back
Armies affected by truces may turn back instead of fighting.
### Battle Request
Eagle creates Shardok battles for provinces where attackers and defenders must fight.
### Food Consumption
Armies consume food before or around battle handling.
### Battle Resolution
Eagle waits for outstanding Shardok battles to report results. If there are none, the phase advances automatically.
### Recon Resolution
Recon results are applied after the relevant strategic activity has completed.
## Player Decision Phases
These phases can pause until required players have submitted decisions.
### Handle Riot
If your province has a riot, you may need to decide how to respond before the round can continue.
### Please Recruit Me
Some unaffiliated heroes may ask to join a faction. The phase waits while available recruitment decisions remain.
### Vassal Commands
Vassals choose province actions for provinces they rule. Vassals lead provinces and make their own choices, so these commands can happen before the direct player command phase.
### Player Commands
This is the main strategic command phase. Each player acts for provinces and heroes they can command directly. Every player is acting within the same phase, so your command is submitted into a shared strategic moment rather than immediately revealing a global timeline.
See [Eagle commands](/commands/) for the command reference.
### Attack Decision
When an army reaches a hostile province, the attacking side may need to choose whether to attack.
### Defense Decision
The defending side may need to decide how to respond to an incoming attack.
### Battle Aftermath
After Shardok results return to Eagle, winners may need to decide what happens to conquered provinces, captured heroes, or multiple allied claimants.
### Diplomacy Resolution
Diplomatic offers are resolved after players choose how to answer them. Offers can be accepted, rejected, invalidated, or lead to consequences such as imprisonment.
## Simultaneous Information
For a focused explanation of public, hidden, and potentially stale observations, see [Information and Recon](/concepts/information-and-recon/).
The important habit is to think in phases, not in a fully visible turn order.
If two factions both issue orders during Player Commands, those orders belong to the same command phase. When the phase ends, each faction receives the filtered results it is allowed to observe. Your own actions are visible to you. Allied and nearby events may be visible depending on game state. Enemy actions may be hidden until scouts, battles, diplomacy, or later consequences reveal them.
This is why Eagle often feels less like "I move, then you move" and more like issuing orders into a living world, then discovering what happened.
@@ -1,135 +0,0 @@
---
title: From Eagle to Shardok
description: How strategic Eagle armies become tactical Shardok battles.
---
<!--
Source map:
- Movement resolution: src/main/scala/net/eagle0/eagle/library/actions/impl/action/PerformProvinceMoveResolutionAction.scala.
- Battle setup phase: src/main/scala/net/eagle0/eagle/library/actions/impl/action/PerformHostileArmySetupAction.scala.
- Uncontested conquest: src/main/scala/net/eagle0/eagle/library/actions/impl/action/PerformUncontestedConquestAction.scala.
- Battle request creation: src/main/scala/net/eagle0/eagle/library/actions/impl/action/RequestBattlesAction.scala.
- Tactical battle resolution back into Eagle: src/main/scala/net/eagle0/eagle/library/actions/impl/action/ResolveBattleAction.scala.
- Province held/conquered outcomes: src/main/scala/net/eagle0/eagle/library/actions/impl/action/ProvinceHeldAction.scala and src/main/scala/net/eagle0/eagle/library/actions/impl/action/ProvinceConqueredAction.scala.
- Multi-winner aftermath: src/main/scala/net/eagle0/eagle/library/actions/impl/action/MultiVictorBattleSetupAction.scala, src/main/scala/net/eagle0/eagle/library/actions/impl/command/BattleAftermathDecisionCommand.scala, and src/main/scala/net/eagle0/eagle/library/actions/impl/action/FinalizeAftermathAction.scala.
- Battle aftermath decisions: src/main/scala/net/eagle0/eagle/library/actions/impl/action/FinalizeAftermathAction.scala and AutoResolveBattleAftermathAction.scala.
-->
Eagle is the strategic layer. Shardok is the tactical layer. The two connect when armies meet in a province and Eagle needs a battlefield result instead of a simple strategic update.
## Strategic Movement
For a compact round-by-round view of the entire process, see [March and Battle Timeline](/concepts/march-timeline/).
In Eagle, armies move through the March command. A moving army has an origin province, destination province, arrival round, heroes, battalions, and supplies.
Because Eagle is simultaneous, multiple factions can send armies during the same strategic phase. Those armies arrive later during movement resolution, after every relevant player has had the chance to submit orders for the phase.
## Arrival
When an army reaches its destination, the first question is whether it is friendly to the province's current ruler.
Friendly armies arrive during Province Move Resolution. Their heroes, battalions, gold, and food enter the destination province at that point.
Hostile armies do not immediately enter the province. They are converted into hostile army groups later in the round, after player commands, with one group per attacking faction. Each group keeps the armies, units, supplies, and approach direction from the province it marched from.
After hostile setup, Eagle checks for cases that do not need a tactical battle:
- If a hostile army reaches an unruled province, the largest army group takes it during Uncontested Conquest. Ties are broken by troop power and then a deterministic random tie-breaker.
- If a single attacking army is attacking and there is no defending army, Eagle can resolve the attack directly as a conquest.
- If diplomacy or truces apply, armies may turn back before fighting.
Otherwise, the player may see attack or defense decision phases before a battle is requested.
## Attack and Defense Decisions
Hostile army groups first wait for an attack decision. An attacking faction can choose whether to press the attack when the game asks for that decision.
If an attack proceeds against a ruled province, the defender may need to choose a defending army. The chosen defenders become the province's defending army. If a fallback province is available, the defender may also need to choose where the army should flee or retreat if Shardok produces that outcome.
## Battle Creation
When Eagle creates a Shardok battle, it sends:
- The province's Shardok map.
- The attacking armies.
- The defending army, if any.
- Each side's food for the battle.
- Victory conditions for each player.
- The battle type and battle id.
Attackers in a normal province assault are trying to take the province. Defenders are trying to hold it or survive long enough for the attacker to fail.
The battle food sent to Shardok is limited. Attackers bring up to one month of food consumption from their carried supplies. Defenders bring up to one month of food consumption from the province's food, and that food is removed from the province when the battle is created.
## Tactical Resolution
The battle then runs in Shardok. Players place units, move, attack, use profession commands, spend Action Points, and try to satisfy their victory conditions.
Eagle waits in the battle resolution phase until outstanding Shardok battles report back.
See [Shardok battles](/concepts/shardok-battles/) for the tactical overview and [Shardok victory conditions](/concepts/shardok-victory-conditions/) for battle-ending rules.
## Returning To Eagle
When Shardok reports a result, Eagle applies battle consequences before deciding who owns the province.
The battle result can change:
- Hero locations and status.
- Battalion size and survival.
- Captured heroes and prisoners.
- Supplies brought by attacking armies.
- Province devastation.
- Quest progress and battle history.
- Prestige. Winners gain battle prestige; losers lose battle prestige.
Battles add devastation to the battle province. Surviving units may return, flee, become outlaws, be secured, or be captured depending on the tactical result.
Attackers' remaining gold and food are added to the battle province as part of battle resolution. If the defender holds the province, those supplies become part of the defended province. If an attacker later conquers it, the province changes hands with the supplies already present.
## If The Defender Wins
If the defending side wins, the province remains with its current ruler.
Attackers who did not flee, become outlaws, or get otherwise secured may be captured by the defending faction. Captured attacker battalions can be destroyed. The defender receives a Province Held result and any captured heroes become prisoners in the province.
## If One Attacker Wins
If exactly one attacking faction wins, that faction conquers the province directly.
Conquest does several things at once:
- Sets the conquering faction as the province's ruler.
- Removes the old ruling-faction heroes from the province.
- Adds the surviving non-fled conquering heroes as the new ruling-faction heroes.
- Replaces the province battalions with the surviving conquering battalions.
- Clears the defending army.
- Sets province support to 0.
- Resets province orders to **Entrust**.
- Captures non-fled defenders and former ruling heroes as prisoners.
- Clears the old owner's focus province if this province was their focus.
- Clears recon on the province for all factions.
Prisoners already held in the conquered province can also be freed. Prisoners from the conquering faction join the new rulers at freed-prisoner loyalty. Prisoners from allied factions are sent toward the closest allied province when possible; if no allied province exists, they can become outlaws.
## If Multiple Allied Attackers Win
If multiple attacking factions win together, Eagle cannot automatically know who should keep the province. It creates a pending conquest decision for the surviving attacking claimants.
During Battle Aftermath, each claimant may be asked to choose:
- **Keep Province:** claim the province. Only one claimant can keep it.
- **Withdraw:** leave for an adjacent province, taking up to the gold and food that claimant brought into the battle.
If an ally has already claimed the province, the remaining claimants must withdraw. If only one undecided claimant remains and nobody has claimed the province, Eagle auto-resolves that claimant as keeping it.
When aftermath finalizes, the keeper conquers the province through the same conquest rules as a single attacking winner. Withdrawing claimants become incoming armies to their chosen adjacent provinces, arriving next round with the gold and food they chose to take. These aftermath withdrawals do not apply shipment transit loss.
## Aftermath
Some battle results need another Eagle decision before the round can continue. This happens mainly when multiple allied attackers win the same province or when captured heroes create follow-up handling decisions.
Battle Aftermath happens after tactical results return but before the strategic round fully moves on. It is not a second Shardok battle; it is Eagle deciding ownership, withdrawals, supplies, and captured-hero consequences from the battle that already ended.
Captured heroes can create separate follow-up decisions after this. Those decisions determine what the capturing faction does with the hero now that the battle result has placed them in custody.
@@ -1,21 +0,0 @@
---
title: Game Concepts
description: Player-facing explanations of Eagle and Shardok systems.
---
These pages explain how Eagle0's major systems fit together. Use them when you want the big picture behind the command reference.
## Eagle
- [Victory and defeat](victory-and-defeat/) explains the strategic objective, leadership succession, faction destruction, and how the game ends.
- [Who you command](command-authority/) explains faction heads, faction leaders, vassals, province turns, and direct control.
- [Eagle round flow](eagle-round-flow/) explains how a strategic round advances, why actions are simultaneous, and when players see results.
- [Information and recon](information-and-recon/) explains public, hidden, scouted, and potentially stale knowledge.
- [From Eagle to Shardok](eagle-to-shardok/) explains how strategic movement turns into tactical battles and how battle results return to Eagle.
- [March and battle timeline](march-timeline/) follows one army from order submission through arrival, battle, and aftermath.
## Shardok
- [Shardok combat fundamentals](shardok-combat/) explains turns, rounds, AP, movement, attacks, terrain, morale, and vigor.
- [Shardok battles](shardok-battles/) explains tactical setup, turns, units, morale, vigor, terrain, and battle aftermath.
- [Shardok victory conditions](shardok-victory-conditions/) explains how tactical battles end.
@@ -1,46 +0,0 @@
---
title: Information and Recon
description: Public, private, stale, and scouted information in simultaneous Eagle rounds.
---
Eagle does not give every faction a current view of the whole world. Results are filtered by province control, faction knowledge, scouting, diplomacy, alliances, and battle participation.
## Three kinds of uncertainty
- **Not present:** the game state confirms that something is absent.
- **Not visible:** it may exist, but your faction has not earned that information.
- **Known as of a date:** your faction observed it previously, but simultaneous actions may have changed it.
A blank enemy field can mean that the value is not visible; it does not necessarily mean the value is zero. An old observation describes the recorded date rather than guaranteeing the current state.
## Province information
Public province information can include identity, ruler, known events, known incoming armies, and an **as of** date. Full local information adds resources, caps, support, food consumption, battalions, heroes, orders, shipments, and events.
Controlling a province normally gives your faction its detailed state. Losing control does not mean every remembered fact remains current; the date tells you when the displayed knowledge was established.
## Simultaneous orders
Enemy factions choose during the same phase. Between an observation and a later arrival, a province may receive troops, change ruler, form a truce, or launch its own army. Observing a target does not reserve or freeze its state.
## Strategic Recon
Recon requires an eligible Ranger and targets a province not ruled by the acting faction. The command creates pending reconnaissance rather than instantly granting omniscience. Results are applied during **Recon Resolution**, after the relevant strategic activity.
Recon improves what your faction knows about the target at that point in the round. Later actions can make those details stale. The observation date records when that knowledge was established.
## Other ways information is earned
- Ruling a province exposes its local state.
- Battle participants learn information revealed by the tactical encounter and its result.
- Diplomacy reveals offers and relationship changes to the involved factions.
- Allied or nearby events may be visible when the game state permits it.
- Moving next to, scouting, or revealing hidden Shardok units affects tactical knowledge separately from strategic Recon.
Alliance does not make every hidden fact in the world public. Each faction can still have knowledge that the other does not.
## Example
In round 8, Recon reports an enemy frontier province with one battalion. You March toward it during round 9. The enemy simultaneously sends reinforcements. Your report was truthful as of round 8, but it did not promise that the round-9 defenders would be unchanged.
See [Eagle Round Flow](/concepts/eagle-round-flow/#simultaneous-information), [Recon](/commands/#recon), and [Provinces](/provinces/) for related rules.
@@ -1,62 +0,0 @@
---
title: March and Battle Timeline
description: Where an army is from order submission through arrival and battle aftermath.
---
March is a delayed strategic order. Selected heroes, battalions, gold, and food leave the origin immediately, but they do not enter the destination until later processing.
## The timeline
| When | State | What happens |
| --- | --- | --- |
| Player Commands, round N | March submitted | Forces and supplies leave the origin and form a moving army scheduled for round N+1. Each marching hero spends vigor. |
| Province Move Resolution, round N+1 | Arrival checked | An army already friendly to the destination enters it. A hostile arrival waits outside rather than entering. |
| After Player Commands, round N+1 | Hostile Army Setup | Hostile arrivals become attacking groups, one per faction, preserving their approach direction and carried supplies. |
| Uncontested Conquest | No tactical defense needed | An unruled destination can be claimed without Shardok. Competing groups are compared by size, power, and a deterministic tie-breaker. |
| Attack and Defense Decisions | A ruled province is contested | Attackers decide whether to press the attack or demand tribute; defenders answer tribute demands and may choose an army and fallback destination. Truces can turn armies back. |
| Battle Request and Resolution | Shardok battle | Eagle sends armies, map, limited battle food, and victory conditions to Shardok, then waits for a result. |
| Battle Aftermath | Consequences applied | Eagle resolves ownership, survivors, prisoners, supplies, devastation, prestige, and any allied-claimant decisions. |
## Friendly arrival
If your faction already rules the destination during Province Move Resolution, the army's heroes, battalions, and supplies enter the province. They are then present before later phases such as player commands and food consumption.
## Neutral or hostile arrival
A marching army does not occupy a neutral or enemy province during movement resolution. It remains a moving/hostile force until conquest or battle processing decides the outcome. This distinction matters for hero location, province leadership, and food.
An unruled province may be conquered without a tactical battle. A ruled and defended province can require attack and defense decisions followed by Shardok.
## Resolving a tribute demand
A tribute demand pauses that attacking faction during Defense Decision. The defender resolves each demand by either paying it in full or refusing it; there is no partial-payment or counteroffer option.
If the defender pays, the full requested gold and food are deducted from the province. The two factions enter a 12-month truce, and the demanding faction's hostile armies against any province ruled by the payer turn back. The returning armies arrive at their origin provinces in the next round.
If the defender refuses, no food or gold is transferred and the demanding army returns to **Attacking** status. The province remains in Defense Decision, so an eligible defender can then choose a defending army and fallback destination. Once the remaining attack and defense decisions are resolved, the ordinary battle-request rules apply: opposed armies proceed to battle, while an undefended attack may instead become an uncontested conquest.
Paying or refusing tribute has no direct prestige effect. If refusal leads to battle, the eventual battle result can change prestige normally.
## Food and gold
Carried supplies travel with the army. Moving armies consume their own food while incoming. If food is insufficient, their battalions can suffer starvation losses.
When a normal province battle is created, attackers bring at most one month of food consumption from their carried food. Defenders receive at most one month from province food, removed when the battle is created. Remaining attacking supplies are applied to the battle province during resolution; whoever ultimately controls the province may therefore inherit them.
## One attacker wins
One victorious attacking faction conquers the province. Its surviving, non-fled heroes and battalions become the new ruling force, support becomes 0, orders reset to Entrust, and eligible defenders can become prisoners.
## Allied attackers win together
Eagle cannot automatically choose which allied winner keeps the province. Claimants enter Battle Aftermath. One may keep it; the others withdraw to adjacent provinces with permitted shares of the supplies they brought. Those withdrawals become incoming armies for the next round.
## Before confirming March
- Confirm the destination and arrival delay.
- Leave enough heroes behind to retain the origin if necessary.
- Carry enough food for transit and the intended battle.
- Remember that the destination's ruler or diplomacy can change before arrival.
- Expect an additional attack, defense, or aftermath decision when the army arrives.
See [March](/commands/#march) for selection rules and [From Eagle to Shardok](/concepts/eagle-to-shardok/) for detailed battle consequences.
@@ -1,80 +0,0 @@
---
title: Shardok Battles
description: How tactical Shardok battles work at a high level.
---
<!--
Source map:
- Shardok battle model: src/main/scala/net/eagle0/eagle/model/state/shardok_battle/ShardokBattle.scala.
- Battle creation from Eagle: src/main/scala/net/eagle0/eagle/library/actions/impl/action/RequestBattlesAction.scala.
- Battle resolution back into Eagle: src/main/scala/net/eagle0/eagle/library/actions/impl/action/ResolveBattleAction.scala.
- Tactical command availability: src/main/cpp/net/eagle0/shardok/library/AvailableCommandsFactory.cpp and command_factories/.
- Victory checks: src/main/cpp/net/eagle0/shardok/library/actions/UpdateGameStatusAction.cpp.
-->
Shardok is the tactical battle layer. Eagle decides that a battle should happen, sends armies into Shardok, and later receives the result back.
For the decision-making basics behind the command list, see [Shardok Combat Fundamentals](/concepts/shardok-combat/).
## Setup
At the start of a battle, each side places its units onto valid setup hexes. Some battles include reserve or reinforcement units that enter later. Rangers on stealth-capable units may be able to deploy hidden.
The map comes from the province where the battle occurs. A province's Shardok map defines terrain, castles, cities, rivers, bridges, water, and critical tiles.
## Units
See [Battalions](/battalions/) for troop, type, training, armament, morale, and lifecycle fundamentals.
A Shardok unit usually represents a hero attached to a battalion. The hero brings stats, vigor, profession abilities, and special command access. The battalion brings troop count, type, morale, training, armament, movement costs, damage profile, and battlefield limits.
Some commands require a profession. Some also require the attached battalion type to support that role. For example, many spell commands require a unit that can cast, while Ranger stealth depends on a stealth-capable battalion.
See [Heroes](/heroes/) for professions and [Shardok commands](/shardok-commands/) for command requirements.
## Action Points
Units act using Action Points. Moving through terrain spends AP based on the terrain and battalion type. Many major actions require a fixed AP minimum and then consume all remaining AP. The [Shardok command reference](/shardok-commands/) lists each command's AP cost and requirements.
Unless a command says otherwise, a unit with morale must have at least 10 morale, and a unit with an attached hero must have at least 10 vigor.
## Turns And Rounds
Shardok advances tactically while units move, attack, cast, hide, scout, flee, and reinforce. When a side ends its turn, control passes according to the tactical state. At the end of a tactical round, Shardok checks end-of-round victory conditions such as holding critical tiles or surviving to the round limit.
Some [victory conditions](/concepts/shardok-victory-conditions/), such as last player standing, can end the battle as soon as only one eligible side remains. Others wait until the end of a round so the opposing side has a chance to respond.
## Terrain
Terrain matters constantly:
- It changes movement costs.
- It affects damage and resistance.
- It can block line of fire.
- Castles and cities can become critical objectives.
- Water, bridges, ice, fire, and mountains create command-specific constraints.
Shardok is not just a troop-count comparison. Position, terrain, hidden information, and command timing matter.
## Hidden Units
Hidden units are not fully visible to enemies. Ranger abilities, scouting, terrain, movement, and proximity can reveal or preserve hidden information.
A hidden unit can matter even at battle end. If a losing hidden unit has a capable hero and can flee, it may escape rather than being resolved like an ordinary visible loser.
## Fleeing, Retreating, And Outlaws
Units can leave a battle through flee or retreat mechanics when those commands are available. A hero may also become an outlaw through battle consequences. Eagle receives these statuses when Shardok reports the final battle result.
## Battle Results
When the battle ends, Shardok reports each player's surviving units, destroyed battalions, fled units, captured or secured units, and victory or loss condition.
Eagle then applies the result:
- Defenders who win hold the province.
- A single attacking winner can conquer the province.
- Multiple allied attacking winners may trigger battle aftermath decisions.
- Captured heroes and prisoners can create follow-up decisions.
- Battle damage can devastate province economy, agriculture, and infrastructure.
- Winning battles can advance quests.
@@ -1,34 +0,0 @@
---
title: Shardok Combat Fundamentals
description: Turns, rounds, Action Points, movement, attacks, terrain, morale, and vigor.
---
Shardok is an objective-based hex battle, not a direct comparison of troop totals. Position, terrain, heroes, hidden information, and command timing determine which force can satisfy its victory conditions.
## Turns, units, and rounds
Players act according to the tactical state. A unit spends **Action Points (AP)** to move or use commands. Ending the player turn sets that player's remaining unit AP to 0. After every required side has had its opportunity, the tactical round closes and end-of-round victory conditions are checked.
## Movement and position
Movement cost depends on terrain and battalion type. Water, mountains, bridges, ice, fire, castles, and cities can restrict paths or change their value. Enemy proximity can affect hiding and movement choices. Use the highlighted valid targets rather than assuming every adjacent hex is traversable.
## Attacks and special actions
Melee and ranged attacks require valid targets and battlefield geometry. Line of fire can be blocked. Damage and resistance depend on the units, heroes, damage profiles, and terrain involved.
Many major actions require a minimum AP and then consume all remaining AP. The command reference states the exact rule. Unless an entry says otherwise, a morale-using unit needs at least 10 morale, and a unit with a hero needs at least 10 [hero vigor](/heroes/#vigor). Current vigor also contributes to maximum AP. Reaching zero vigor resolves the entire hero-led unit as captured, even if troops remain.
## Heroes and battalions
The battalion provides troops, type, morale, training, armament, movement, and damage behavior. The attached hero provides stats, vigor, profession abilities, and special commands. Profession commands can also require a compatible battalion: casting and Ranger stealth are important examples.
## Terrain and objectives
Castles and cities may be defensive positions or critical objectives. Critical-tile victory normally requires a hero-bearing unit on every required tile at the end of the tactical round. Occupying an objective during your turn is therefore not always immediate victory; opponents may receive a chance to respond.
## Hidden units
Hidden units are not fully visible. Ranger scouting, adjacency, movement, and terrain can reveal them. Hidden units do not keep a player alive for Last Player Standing, although a capable hidden loser may flee when battle ends.
See [Shardok Commands](/shardok-commands/) for exact action requirements and [Shardok Victory Conditions](/concepts/shardok-victory-conditions/) for battle-ending checks.
@@ -1,80 +0,0 @@
---
title: Shardok Victory Conditions
description: How tactical battles decide winners and losers.
---
<!--
Source map:
- Victory condition enum: src/main/protobuf/net/eagle0/common/victory_condition.proto.
- Shardok victory checks: src/main/cpp/net/eagle0/shardok/library/actions/UpdateGameStatusAction.cpp.
- Eagle assignment of battle victory conditions: src/main/scala/net/eagle0/eagle/library/actions/impl/action/RequestBattlesAction.scala.
-->
Shardok battles end when one of the battle's victory conditions is satisfied. Each player has a set of conditions that apply to them.
In a normal Eagle province assault, attackers usually get **Hold Critical Tiles**, **Last Player Standing**, and **Last Alliance Standing**. The defender usually gets **Last Player Standing** and **Win After Max Rounds**.
## When conditions are checked
A **turn** is one player's opportunity to act. A **round** includes the required turns before end-of-round processing. This distinction determines whether the opposing side gets a response.
| Condition | Typical holder | Check timing |
| --- | --- | --- |
| Last Player Standing | Attacker and defender | As soon as only one eligible player survives |
| Last Alliance Standing | Allied attackers | As soon as the only eligible survivors are mutually allied holders |
| Hold Critical Tiles | Attackers | End of the tactical round |
| Win After Max Rounds | Defender | End-of-round clock check |
| Most Troops Standing | Special battle types | At its configured ending check |
The battle display's assigned conditions and maximum-round value are authoritative for that battle; the normal-assault assignments above are defaults, not promises for every scenario.
### Critical-tile response example
An attacker occupies the final critical tile during their turn. The battle does not end at that movement step. The defender receives the remaining opportunity provided by the tactical round and can retake or contest an objective. If eligible allied attackers still occupy every critical tile when the round closes, Hold Critical Tiles succeeds.
### Defender-clock example
The defender does not need to eliminate every attacker when Win After Max Rounds is assigned. The attacker must satisfy an attacking victory condition before the displayed round limit expires. Surviving without taking the objectives can therefore be a loss for the attacker.
## Last Player Standing
A player wins if they are the only surviving player and they have this victory condition.
A player counts as surviving when they still have a visible normal unit with troops, or a visible normal unit with a living hero. Hidden units do not keep a player alive for this check, though some hidden losing units may be able to flee when the battle ends.
## Last Alliance Standing
Multiple players can win together if all surviving players are mutually allied and all have this victory condition.
This is the tactical version of "the only forces left on the field are allies."
## Win After Max Rounds
A player wins if the battle lasts past the maximum number of Shardok rounds and they have this victory condition.
In normal province assaults, this is usually the defender's clock. The attacker has to force a result before time runs out.
## Hold Critical Tiles
A player wins by controlling every critical tile on the map. Critical tiles are usually castles or other objective tiles.
To count, each critical tile must be occupied by a unit belonging to the winning player, and the occupying unit must have an attached hero.
Allied attackers can also win this way together if allied players collectively occupy all critical tiles, all occupying players have the condition, and the occupants are mutually allied.
This condition is checked at the end of a tactical round, not immediately after every move, so the defender has a chance to retake objectives before the round closes.
## Most Troops Standing
This condition exists in the battle model, but normal Eagle province assaults do not currently assign it to attackers or defenders.
If a battle type uses it, the winner is the player with the most troops standing when the battle reaches the relevant ending check.
## What Happens After Victory
Shardok reports each player as victorious or defeated, including which condition ended the battle. Eagle then turns that tactical result into strategic consequences:
- A defending winner holds the province.
- A single attacking winner conquers the province.
- Multiple allied attacking winners may need to decide who keeps the province.
- Captured heroes, destroyed battalions, fled units, outlaws, and secured prisoners are applied to the Eagle game state.
@@ -1,62 +0,0 @@
---
title: Victory and Defeat
description: How factions win, survive leadership losses, and leave the game.
---
Eagle0 is a struggle to become the last surviving faction. The game ends when only one faction remains; that faction wins. If no factions remain, the game ends without a winner.
## When a faction survives
A faction remains in the game while it has both:
- at least one faction leader; and
- at least one ruled province or moving army.
Losing a province is therefore serious, but it is not necessarily immediate defeat. An army already marching can keep a landless faction alive long enough to conquer somewhere else.
## Losing your faction head
The death of the faction head does not automatically destroy the faction. Another surviving faction leader succeeds them.
If no faction leader is available but the faction still has a province or moving army, the strongest surviving vassal can rise to become both faction leader and faction head. The faction is destroyed only if no vassal can succeed.
Creating additional leaders with **Swear Kinship** protects the faction against a sudden leadership loss. A candidate must belong to the faction, have at least 95 loyalty, and be with the faction head when the command is used.
## How a faction is destroyed
A faction is destroyed when either condition is true:
1. It rules no provinces and has no moving armies.
2. It has no faction leaders after the succession check.
When a faction is destroyed:
- its provinces become unruled;
- its incoming armies are removed and their supplies remain at their destinations;
- its heroes lose their faction assignment; and
- diplomacy and outstanding offers involving it are removed.
Those heroes are not necessarily killed. They may remain in the world in unaffiliated states and interact with surviving factions later.
## Strategic implications
- Keep a succession option by gaining another leader or maintaining a capable vassal.
- Preserve at least one province or viable moving army.
- Do not assume an enemy is eliminated merely because its last visible province falls; it may still have an army in motion.
- Remember that conquest can release, capture, or displace heroes without destroying their former faction immediately.
## Quick answers
**Do I win a battle by killing the enemy faction head?**
Not automatically. Leadership succession may keep the faction alive.
**Am I defeated as soon as I lose my last province?**
Not if your faction still has a moving army and a leader.
**Can a faction with provinces survive after all its leaders die?**
Only if a surviving vassal can rise during the succession check.
**How does the whole game end?**
The final surviving faction wins. If every faction is destroyed, there is no winner.
For tactical battle victory rules, see [Shardok Victory Conditions](/concepts/shardok-victory-conditions/). For leadership roles, see [Heroes](/heroes/#faction-leaders) and [Factions](/factions/#leaders).
@@ -1,136 +0,0 @@
---
title: Factions
description: Reference for Eagle factions, leaders, prestige, diplomacy, and province focus.
---
Factions are the political actors in Eagle. They control provinces, command heroes and battalions, negotiate diplomacy, and compete for victory.
<!-- Sources:
- FactionView shape: src/main/protobuf/net/eagle0/eagle/views/faction_view.proto
- Relationship levels: src/main/protobuf/net/eagle0/eagle/views/faction_relationship_view.proto
- Trust storage: src/main/scala/net/eagle0/eagle/model/state/faction/FactionRelationship.scala
- Swear Kinship availability: src/main/scala/net/eagle0/eagle/library/actions/availability/AvailableSwearBrotherhoodCommandFactory.scala
- Swear Kinship effect: src/main/scala/net/eagle0/eagle/library/actions/impl/command/SwearBrotherhoodCommand.scala
- Leadership and destruction: src/main/scala/net/eagle0/eagle/library/actions/impl/action/CheckForFactionChangesAction.scala
- Free hero states and recruitment info: src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero/UnaffiliatedHeroT.scala
- Recruitment availability and odds: src/main/scala/net/eagle0/eagle/library/actions/availability/AvailableRecruitHeroesCommandFactory.scala
and src/main/scala/net/eagle0/eagle/library/util/recruitment_odds/RecruitmentOdds.scala
- Prestige calculation: src/main/scala/net/eagle0/eagle/library/util/faction_utils/FactionUtils.scala
- Truce, alliance, break-alliance, ransom, and imprisonment effects:
src/main/scala/net/eagle0/eagle/library/actions/impl/action/diplomacy_helpers/
- Trust constants: src/main/scala/net/eagle0/eagle/library/settings/BUILD.bazel
-->
## Faction Head
The faction head is the hero who represents the faction. In a normal game, this is usually your starting leader: the hero whose ideals, title, and story define what your faction is about.
If the faction head is killed, another faction leader can take over as faction head. The faction may also be renamed around the new head if that hero has a faction name of their own.
## Leaders
[Faction leaders](/heroes/#faction-leaders) are the heroes you can command directly. They are different from [vassals](/heroes/#vassals), who rule provinces for you and make their own choices.
You can gain more faction leaders with [**Swear Kinship**](/commands/#swear-kinship). The command is available when your faction head is in the province, your faction has fewer than 5 leaders, and another hero in the province has at least 95 loyalty. The new kinship hero becomes a faction leader.
Faction leaders matter for succession. If the faction head dies, the next available faction leader becomes faction head. If no surviving leader is available to take over immediately, the strongest remaining vassal can rise to become a new faction leader and faction head.
## Prestige
Prestige is a high-level measure of faction stature. It is also the main faction-wide factor in whether free heroes are willing to join you.
You gain prestige by:
- Controlling more provinces whose support is high enough for taxes. Each supported province is worth 10 prestige.
- Winning battles.
- Defeating beasts.
Prestige is not the only recruitment factor. The faction head's charisma, the free hero's power and ambition, the hero's current state, and any personal bias toward or against your faction also matter. But when you want more heroes to take you seriously, prestige is the big strategic lever.
## Free Heroes
Free heroes are heroes in a province who do not currently belong to your faction's command structure. The Free Hero panel tells you what state they are in and whether they can be recruited, divined, or managed another way.
The common states are:
- **Resident:** A hero living in the province. Residents may be willing to join outright. If they are not ready, they may be divinable: use [**Divine**](/commands/#divine) to learn what would impress them, then fulfill that ask to make them recruitable.
- **Traveler:** A hero passing through. You do not know whether a traveler would join you until a faction leader travels there and talks to them. Travelers are harder to recruit than residents, but they might still join if your prestige and other recruitment factors are strong enough. They are also likely to leave the province soon, so they are more time-sensitive than residents.
- **Prisoner:** A captured hero. Prisoners start with a strong bias against the faction holding them, but desperation can build over time. Eventually a prisoner may become willing to join, especially if your prestige is high enough.
- **Outlaw:** A hostile free hero in the province. Outlaws are not ordinary recruits and will not simply join from the Free Hero panel; you generally deal with them through commands such as [**Apprehend Outlaw**](/commands/#apprehend-outlaw).
Some free heroes are effectively unavailable until circumstances change. For example, a province without one of your faction leaders cannot recruit from the Free Hero panel, and a hero may refuse forever if they have a permanent reason not to join your faction.
### What `WOULD_JOIN` Means
`WOULD_JOIN` means the hero meets the normal recruitment threshold for the faction that rules their current province. It is faction-specific: another faction may not qualify to recruit the same hero. The calculation uses the ruling faction's prestige, its faction head's charisma, the free hero's power and ambition, the hero's current state, and any personal bias toward or against that faction. A hero who is permanently unwilling to join that faction does not qualify.
The status means **willing, not recruited**. To recruit the hero with [**Recruit Heroes**](/commands/#recruit-heroes):
1. The province must be ruled by one of your faction leaders.
2. That ruling faction leader must personally use [**Visit Town**](/commands/#visit-town).
3. Use **Recruit Heroes** while the leader is visiting town and select the hero.
The faction head does not have to visit personally. Any of your faction leaders can recruit in a province they rule, although the willingness calculation still uses the faction head's charisma.
Vassals may visit town for their own purposes, but they cannot use **Recruit Heroes**, so they do not automatically recruit a hero marked `WOULD_JOIN`. However, an eligible free hero in a vassal-ruled province with enough support to provide taxes may independently ask to join during the **Please Recruit Me** phase. That is a separate faction-level decision: a human player accepts or declines the request rather than the vassal deciding. AI-controlled factions accept such requests automatically.
`WOULD_JOIN` is not the only status that can lead to recruitment. Travelers continue to display `TRAVELER` even when they would accept, because their willingness remains unknown until a faction leader visits town. Once the leader visits, the authoritative **Recruit Heroes** list shows which free heroes that faction can recruit under the current circumstances.
## Relationships
Faction relationships have a visible diplomatic state and an internal trust value.
The visible relationship states are:
- **Hostile:** The default enemy state. Hostile factions can fight each other.
- **Truce:** A temporary peace. Truce offers set a reset date; when that date passes, the relationship returns to hostile. A truce or alliance also causes hostile armies between those factions to turn back during the truce turn-back phase.
- **Alliance:** A stronger cooperative relationship. Alliances do not have a reset date. They last until they are broken.
Trust is a hidden number used by AI diplomacy. Higher trust makes a faction more willing to accept diplomatic offers. Trust changes in a few ways:
- Trust rises by 1 each new round between every pair of factions.
- Accepting a ransom offer raises the offering faction's trust in the faction that accepted it by 25.
- Rejecting a ransom offer lowers the offering faction's trust in the rejecting faction by 25.
- Imprisoning another faction's ambassador is a major trust hit: the ambassador's faction loses 150 trust toward the imprisoning faction.
- Other factions also notice ambassador imprisonment: each neutral faction loses 40 trust toward the imprisoning faction.
## Incoming Diplomacy Offers
Incoming offers are unresolved diplomatic proposals from other factions. They become [special and conditional commands](/commands/#special-and-conditional-commands) such as resolving truce offers, alliance offers, invitations, ransom offers, or alliance breaks.
## Diplomacy Outcomes
An envoy sent with a truce, invitation, alliance, or break-alliance proposal leaves the acting province during **Player Commands**. The hero is unavailable for the rest of that round, including any battles, and normally returns during **Diplomacy Resolution** after the battles are complete. If the origin province is still controlled, the envoy returns there; otherwise the game tries another faction province and then a moving faction army. If none exists, the envoy becomes an outlaw in the origin province.
Accepting another faction's alliance break replaces the alliance with a truce. Its reset date is 12 months after acceptance. The truce remains in force through that reset month, then becomes **Hostile** when **New Round** advances the date beyond it. Accepting a break therefore provides a protected transition instead of permitting immediate war.
Imprisoning a messenger has concrete consequences:
- The messenger does not return. They leave their original faction and become a prisoner held in the imprisoning faction's senior faction leader province.
- The messenger receives a -100 faction bias toward the imprisoning faction, making later recruitment much harder.
- The messenger's original faction loses 150 trust toward the imprisoning faction.
- Every third-party faction loses 40 trust toward the imprisoning faction.
- The incoming offer is removed. If the message was an alliance break, the alliance ends immediately, both sides become **Hostile**, and no transition truce is created.
## Focus Province
A faction can have a focus province. The focus province helps direct [province orders](/provinces/#province-orders) and AI attention toward a particular province.
## Faction Destruction
For a player-oriented overview of winning, losing, and leadership succession, see [Victory and Defeat](/concepts/victory-and-defeat/).
A faction is destroyed if either of these is true:
- It has no ruled provinces and no moving armies.
- It has no faction leaders left.
The leader case has one extra succession step. If a faction loses its available leaders but still has provinces or armies, the game first looks for the strongest surviving vassal and promotes that hero to faction leader and faction head. If no vassal can rise, the faction has no faction leaders left and is destroyed.
When a faction is destroyed:
- Its provinces lose their ruling faction.
- Its incoming armies are removed, and their supplies are left in the province.
- Its heroes lose their faction assignment.
- Other factions lose relationships and incoming offers involving the destroyed faction.
- If only one faction remains, the game ends and that faction wins. If no factions remain, the game ends with no winner.
@@ -1,270 +0,0 @@
---
title: Heroes
description: Reference for Eagle heroes, stats, professions, and battlefield roles.
---
Heroes are named characters who lead factions, rule provinces, command troops, perform strategic commands, and appear as units in Shardok battles.
The player-facing hero model is `HeroView`. It includes identity, faction membership, profession, core stats, personality stats, vigor, loyalty, abilities, portrait path, and backstory text.
## Core Stats
- **Strength** affects physical power and contributes to combat-oriented commands.
- **Agility** affects mobility, scouting-style competence, and some profession eligibility.
- **Constitution** sets a hero's endurance profile and is closely tied to vigor.
- **Charisma** affects leadership, loyalty, diplomacy-flavored actions, and some profession eligibility.
- **Wisdom** affects knowledge, magic-flavored actions, and some profession eligibility.
Stats can also have XP values. Strategic commands often spend vigor and grant XP in the stats connected to that action.
## Personality Stats
- **Integrity** represents trustworthiness and moral reliability.
- **Ambition** represents appetite for power or advancement.
- **Gregariousness** represents sociability and ease with other heroes.
- **Bravery** represents willingness to face danger.
These values help drive hero flavor, AI decisions, and story generation.
## Vigor
Vigor is a hero's current energy reserve. **Constitution is the hero's normal strategic vigor cap**; vigor and Constitution are related, but they are not the same stat.
### Strategic vigor
Many Eagle commands require an acting hero and spend vigor. The command reference lists each cost. A hero with too little vigor is not offered as an actor for that command.
Vigor recovers over time and through deliberate recovery:
- Each new month restores 5 vigor.
- [Rest](/commands/#rest) restores up to 15 vigor to each faction hero in the province.
- [Feast](/commands/#feast) restores up to 10 vigor to each faction hero in the province.
- Recovery cannot raise strategic vigor above Constitution.
Strategic actions that spend vigor also grant Constitution XP. Constitution can rise through normal stat advancement, increasing the hero's future strategic vigor cap.
### Vigor in a Shardok battle
A hero enters battle with the vigor they currently have after strategic costs such as marching. That value becomes the hero's **battle-start vigor** and the recovery ceiling for that battle. A tired hero can therefore enter with a battle recovery ceiling below their Constitution. The ceiling does not fall as the battle proceeds; current vigor falls and rises beneath it.
Tactical vigor affects what a hero-led unit can do:
- Higher current vigor contributes to the unit's maximum Action Points. A normal unit's maximum is 5 plus rounded `vigor / 25` plus rounded `morale / 25`.
- Spending Action Points normally spends 0.5 vigor per AP, and some profession abilities have additional vigor costs.
- Combat damage and battlefield effects can reduce vigor.
- Ending an activation with unused AP, [Rest](/shardok-commands/#stop-or-rest), and effects such as [Holy Wave](/shardok-commands/#holy-wave) can restore vigor, but not above battle-start vigor.
- Below 10 vigor, most ordinary tactical commands are unavailable. A few cleanup or required commands are exceptions.
- At zero vigor, the hero-led unit is resolved as captured. Its surviving battalion does not remain on the map as an independently acting unit.
While the hero remains active, their stats affect the battalion's combat swing, they add personal damage, and their profession can unlock special commands. Low vigor does not directly scale those contributions down point by point; it reduces AP and command access, with the decisive removal occurring at zero.
When a tactical display shows `current / battle start`, the first number is current vigor and the second is the battle-start recovery ceiling—not Constitution. Enemy details may be hidden by battlefield knowledge. An absent or hidden enemy value must not be interpreted as an actual zero.
## Loyalty
Loyalty measures a hero's attachment to their faction. Feasts, events, captures, faction bias, and story consequences can move loyalty over time.
Loyalty is not faction bias. Loyalty describes an affiliated hero's current attachment; bias helps determine whether an unaffiliated or imprisoned hero will join a particular faction.
### Loyalty Departure
Any hero with low loyalty (below 50) may leave at any time. The game checks for departures automatically during the **Hero Departures** phase of each strategic round, before players and vassals issue commands. Lower loyalty means a greater chance of departure.
When a hero leaves, they stop belonging to the faction and become an unaffiliated traveler in the same province. They do not take gold or food with them. If other faction heroes remain, the province and its supplies remain with the faction. If every affiliated hero leaves, the province becomes unowned and its stored gold and food are lost.
## Professions
Professions give heroes special command access, bonuses, and tactical identity. Some effects are Eagle strategic effects, while others apply only when the hero is attached to a Shardok battle unit.
Several Shardok profession commands also depend on the attached battalion type. For example, many magic commands require a battalion type that allows casting, and stealth commands require a battalion type that allows stealth.
### Mage
Eagle effects:
- Unlocks **Control Weather** for a mage in the acting province with enough vigor.
- Can start or end blizzards and droughts in the acting province or a neighboring province.
- Counts as a profession for general hero power and riot-crackdown hero power.
- Makes the hero naturally capable of starting fires.
Shardok effects:
- Unlocks **Lightning Bolt** when attached to a casting-capable unit.
- Unlocks the multi-round **Meteor** command chain: start, target, cast, and cancel.
- Unlocks **Freeze Water** on adjacent water when attached to a casting-capable unit.
- Improves **Start Fire** odds when the unit can start fires.
- Changes hero melee damage from physical damage to magical damage: lightning, cold, fire, and aether essence.
- Can influence AI water-crossing evaluation because Mage can freeze water.
Profession gain:
- Wisdom is the qualifying stat for Mage.
### Necromancer
Eagle effects:
- Unlocks **Start Epidemic** for a necromancer in the acting province with enough vigor.
- Epidemics can target the acting province or neighboring provinces, excluding friendly provinces that already have an epidemic.
- Counts as a profession for general hero power and riot-crackdown hero power.
Shardok effects:
- Unlocks **Raise Dead** when attached to a casting-capable unit and not already controlling a unit.
- Unlocks **Fear** against eligible enemy units when attached to a casting-capable unit.
- Raised dead and fear use the hero's charisma and wisdom in their odds.
Profession gain:
- Charisma is one qualifying stat for Necromancer.
### Engineer
Eagle effects:
- Is recommended first for **Improve** when an engineer is available.
- Adds the configured engineer bonus to province improvements and devastation repair.
- Counts as a profession for general hero power and riot-crackdown hero power.
Shardok effects:
- Unlocks **Build Bridge** from non-water terrain onto adjacent water.
- Unlocks **Repair** for damaged bridges and castles.
- Unlocks **Blow Bridge** while standing on a bridge.
- Unlocks **Reduce** from a castle or fortified position against enemy units or castles.
- Unlocks **Fortify** on fortifiable terrain.
- Can influence AI water-crossing evaluation because engineers can create and destroy bridge access.
Profession gain:
- Agility is one qualifying stat for Engineer.
### Paladin
Eagle effects:
- Improves **Alms** by multiplying the support gained per food given.
- Is sorted first in the **Alms** hero list when eligible.
- Counts as a profession for general hero power and riot-crackdown hero power.
Shardok effects:
- Unlocks **Holy Wave** when the paladin has enough action points.
Profession gain:
- Charisma is one qualifying stat for Paladin.
### Ranger
Eagle effects:
- Unlocks **Recon** for a ranger in the acting province with enough vigor.
- Recon can target provinces not ruled by the acting faction, as long as that faction does not already have a pending recon there.
- Counts as a profession for general hero power and riot-crackdown hero power.
Shardok effects:
- Unlocks **Scout** when attached to a stealth-capable unit.
- Unlocks **Hide** when attached to a stealth-capable unit, outside enemy zone of control, and with multiple hideable positions.
- Allows defending rangers with stealth-capable units to use hidden placement during setup.
- Gives a bonus to **Brave Water** odds when the unit can brave water.
- Reveals adjacent hidden enemies while moving.
- Prevents adjacent enemies from hiding or remaining hidden when the ranger has enough knowledge to be identified.
Profession gain:
- Agility is one qualifying stat for Ranger.
### Champion
Eagle effects:
- Is recommended first for **Train** when a champion is available.
- Adds the configured champion bonus to training gained by trainable battalions.
- Counts as a profession for general hero power and riot-crackdown hero power.
Shardok effects:
- Unlocks **Challenge Duel** against adjacent enemy heroes outside castles.
- The target must have an attached hero and must either have enough known vigor to accept or be insufficiently known to rule that out.
Profession gain:
- Strength is the qualifying stat for Champion.
### Warden
Eagle effects:
- Improves prisoner recruitment over time. At new round processing, prisoners in a province with a ruling-faction Warden gain additional faction bias toward that province's ruler.
- Counts as a profession for general hero power and riot-crackdown hero power.
Shardok effects:
- Unlocks **Evacuate Prisoners** when the warden's unit can flee, has enough action points, and hostile captured hero units are present.
Profession gain:
- Constitution is the qualifying stat for Warden.
### NoProfession
Heroes with no profession have no profession-specific command unlocks. They can still lead, fight, train, improve, march, use many normal strategic commands, and gain a profession later.
## Profession Gain
Only heroes with **NoProfession** can gain a profession. At the end of a round, heroes with enough XP can gain stats. If a no-profession hero gains one or more stats, the game checks for profession gain:
- The hero gets one profession-gain roll for each stat that increased.
- A stat must be at least **85 after the stat gains are applied** to qualify a profession.
- Eligible professions are based on all post-gain stats at or above 85, not only the stats that increased that round.
- Strength qualifies **Champion**.
- Agility qualifies **Engineer** and **Ranger**.
- Wisdom qualifies **Mage**.
- Charisma qualifies **Necromancer** and **Paladin**.
- Constitution qualifies **Warden**.
### Progression example
A NoProfession hero earns Agility-related XP from strategic actions. During end-of-round progression, enough XP may increase Agility. If the hero's Agility is at least 85 after that increase, Engineer and Ranger are eligible. The hero receives profession-gain rolls for stats that increased; the player does not directly choose between eligible professions.
If several stats increase together, each increase can create a roll and all post-gain qualifying stats participate in eligibility. A hero who already has a profession does not roll for a replacement.
### Planning progression
- Commands award XP in stats associated with the action.
- Stat increases and profession checks happen during end-of-round processing, not when XP is awarded.
- Reaching 85 in a qualifying stat makes its profession possible but does not guarantee a particular random result.
- Choosing which eligible hero performs a command influences who receives its XP.
## Faction Leaders
For a side-by-side explanation of direct control, province turns, and vassal automation, see [Who You Command](/concepts/command-authority/).
Every faction starts with a **faction head**. The faction head is the hero who defines what the faction represents: its political identity, its public story, and the starting point for its leadership.
Factions can gain additional faction leaders through **Swear Kinship** with another hero. The candidate must already belong to the faction, must have at least 95 loyalty, and must be in a province with the faction head. When the command succeeds, that hero becomes one of the faction's leaders.
Faction leaders are directly commandable. Vassals, by contrast, lead provinces and make their own choices when their province acts. Promoting a loyal vassal into leadership gives the player more direct control over that hero.
Faction leaders also provide succession. If the faction head is killed, another available faction leader can take over as the new faction head.
## Vassals
A **vassal** is a hero who belongs to your faction and rules one of its provinces, but is not a faction leader. You cannot command vassals directly. Instead, their provinces act during the Vassal Commands phase, when each vassal chooses an action based on the province's standing orders and immediate needs.
Vassals can be given standing orders with **Issue Orders**, promoted to faction leaders with **Swear Kinship**, or removed from the faction with **Exile Vassal**.
## Unaffiliated Heroes
Unaffiliated heroes can appear in provinces before joining a faction. Their province entry includes name, profession, status, leader flag, and recruitment information.
Common interactions include recruiting them, divining more information about them, declining their quests, or responding when they ask to join.
## Battle Capabilities
Hero views expose whether a hero is archery-capable or start-fire-capable. Those capabilities affect Shardok command availability when the hero is represented in battle.
- **Archery-capable**: the hero needs at least 75 agility.
- **Start-fire-capable**: the hero is always capable if they are a Mage. Otherwise, they need at least 70 agility and at least 70 wisdom.
@@ -1,26 +0,0 @@
---
title: Eagle0pedia
description: Reference companion for Eagle0 commands, systems, and game concepts.
---
Eagle0pedia is the in-game reference companion for Eagle0. The tutorial answers "what should I do next?"; these pages answer "what does this mean?"
Eagle0 is a strategy game about factions, heroes, provinces, armies, diplomacy, and tactical battles. The broad strategic layer is **Eagle**: provinces produce food and gold, heroes lead factions or rule as vassals, armies march across the map, and diplomacy shapes the war. When armies clash, battles are resolved in **Shardok**, the tactical hex-based combat system.
Your strategic goal is to become the last surviving faction. Start with [Victory and Defeat](concepts/victory-and-defeat/) to learn how factions win, survive leadership losses, and leave the game.
Use these pages when you want a quick reference for commands, hero professions, faction leadership, province stats, or tactical battle options.
## Sections
- [Game concepts](concepts/) explains the flow of Eagle rounds, the handoff from Eagle to Shardok, tactical battles, and victory conditions.
- [Eagle round flow](concepts/eagle-round-flow/) explains strategic phases and simultaneous player action.
- [From Eagle to Shardok](concepts/eagle-to-shardok/) explains how strategic armies become tactical battles.
- [Shardok battles](concepts/shardok-battles/) explains tactical battle structure, units, terrain, and aftermath.
- [Shardok victory conditions](concepts/shardok-victory-conditions/) explains how tactical battles end.
- [Eagle commands](commands/) explains the strategic commands available from province command panels.
- [Shardok commands](shardok-commands/) explains tactical battle commands and profession-gated battlefield actions.
- [Heroes](heroes/) covers stats, vigor, loyalty, professions, faction leaders, and battle capabilities.
- [Battalions](battalions/) covers troop count, type, training, armament, morale, heroes, and battlefield outcomes.
- [Factions](factions/) covers faction heads, leaders, prestige, diplomacy, and focus provinces.
- [Provinces](provinces/) covers province stats, supplies, orders, armies, maps, castles, and unaffiliated heroes.
@@ -1,19 +0,0 @@
---
title: Integration Notes
description: Technical notes for Eagle0pedia links, deployment, and review.
---
Eagle0pedia lives close to the game data and can be reviewed with code changes before being deployed to `pedia.eagle0.net`.
## Link Targets
Unity info buttons should link to stable concept URLs. For the command panel, start with anchors on the command reference page:
- `https://pedia.eagle0.net/commands/#train`
- `https://pedia.eagle0.net/commands/#march`
- `https://pedia.eagle0.net/commands/#diplomacy`
- `https://pedia.eagle0.net/shardok-commands/#melee`
- `https://pedia.eagle0.net/heroes/#professions`
- `https://pedia.eagle0.net/provinces/#province-stats`
When individual command pages are split out, keep redirects or aliases for these anchors so older clients still land somewhere useful.
@@ -1,229 +0,0 @@
---
title: Provinces
description: Reference for Eagle provinces, resources, infrastructure, orders, and tactical maps.
---
Provinces are the strategic map locations in Eagle. They hold resources, heroes, battalions, province orders, incoming armies, local events, and the Shardok map used when battles occur there.
The player-facing province model is `ProvinceView`. Public information includes id, name, ruling faction, known events, known incoming armies, and an `as_of` date. When you have full information, `FullProvinceInfo` adds the detailed local state: gold, food, caps, support, food consumption, battalions, heroes, orders, incoming shipments, and local events.
<!--
Source map:
- Province view fields: src/main/protobuf/net/eagle0/eagle/views/province_view.proto and src/main/scala/net/eagle0/eagle/library/util/view_filters/ProvinceViewFilter.scala.
- Province utilities: src/main/scala/net/eagle0/eagle/library/util/province/ProvinceUtils.scala.
- New year production and yearly degradation: src/main/scala/net/eagle0/eagle/library/actions/impl/action/NewYearAction.scala.
- New round caps, price-index drift, empty-province devastation repair, and hero-cap loyalty pressure: src/main/scala/net/eagle0/eagle/library/actions/impl/action/NewRoundAction.scala.
- Food consumption and starvation: src/main/scala/net/eagle0/eagle/library/actions/impl/action/PerformFoodConsumptionPhaseAction.scala.
- Shipment arrival: src/main/scala/net/eagle0/eagle/library/actions/impl/action/ShipmentArrivedAction.scala and src/main/scala/net/eagle0/eagle/library/actions/impl/action/PerformProvinceMoveResolutionAction.scala.
- Price index: src/main/scala/net/eagle0/eagle/library/util/PriceIndexUtils.scala.
- Settings: src/main/resources/net/eagle0/eagle/settings.tsv.
-->
## Strategic Role
A province is both a resource base and a staging point. It can:
- Produce gold and food at the new year.
- Store gold and food between commands.
- Feed stationed battalions each round.
- Hold heroes, battalions, prisoners, unaffiliated heroes, and events.
- Launch March, Send Supplies, Train, Improve, Arm Troops, Trade, and other province commands.
- Become a Shardok battlefield when hostile armies meet there.
The important strategic question is usually not just "how high are the stats?" It is "can this province keep support above 40, feed its troops, store enough supplies, and stage the actions I need before another faction interferes?"
## Development Stats
The three main development stats are **economy**, **agriculture**, and **infrastructure**.
- **Economy** drives annual gold production. A supported province produces 35 gold per effective economy at the new year.
- **Agriculture** drives annual food production. A supported province produces 50 food per effective agriculture at the new year.
- **Infrastructure** increases gold and food storage caps, affects improvement quality, and caps how far battalions can be armed.
Each stat has a matching devastation track. The effective value is the stat minus its devastation, never below 0. For example, 80 agriculture with 25 agriculture devastation behaves like 55 effective agriculture for production and other effective-stat checks.
Improve can raise economy, agriculture, or infrastructure. It can also repair devastation across all three devastation tracks. Engineers get a bonus when improving.
## Support
Support is local backing for the ruling faction. It ranges from 0 to 100 and is one of the most important province numbers.
A province needs at least **40 support** to provide annual gold and food production. If support is below 40, the province does not produce taxes at the new year. That also matters for prestige: supported provinces are worth prestige to the ruling faction.
Support changes through several commands and events:
- **Alms** spends food to raise support.
- **Improve** raises support as a side effect.
- **Riot** damage reduces support by 25 if the riot is not stopped.
- **Suppress Beasts** can raise support on success.
- Province events can raise or lower support.
Support also affects risk. Low support makes the province less useful economically and affects riot timing.
## Gold
Gold pays for many strategic actions. Feast, Organize Troops, Arm Troops, Divine, Hero Gift, diplomacy, Trade buys, and some other commands spend gold.
At the new year, supported provinces gain annual gold equal to:
`effective economy * 35`
Gold is stored in the province until spent, traded, shipped, marched away, captured through conquest, or lost because the province is over its storage cap.
Feast costs gold, not food: 20 gold per ruling-faction hero in the province, multiplied by the province price index.
## Food
Food supports armies and can also be spent strategically.
At the new year, supported provinces gain annual food equal to:
`effective agriculture * 50`
Food is consumed during the Food Consumption phase by battalions stationed in the province and by moving armies. The province view exposes monthly food consumption so you can judge how long the province can feed its current troops.
Food is also used by:
- **Alms**, which spends food to raise support.
- **Trade**, which buys or sells food for gold.
- **Send Supplies**, which can ship food to another province.
- **March**, which can carry food with moving armies.
- Riot gifts, if you choose to give food.
## Food Consumption and Starvation
Food consumption happens during the Food Consumption phase, after Province Move Resolution, player commands, hostile army setup, and battle requests, but before Battle Resolution.
This timing matters:
- A Send Supplies shipment that arrives during Province Move Resolution is available before the destination province consumes food later that same round.
- A friendly marching army that arrives during Province Move Resolution can also bring food before that round's food consumption.
- Moving armies consume food from their own carried supplies while they are still incoming.
If a province cannot feed its battalions, it spends all remaining province food and the battalions are reduced. The maximum battalion reduction from a food shortage is 20% in a single food-consumption event.
The same starvation rule applies to moving armies: if the army's carried food is not enough, the moving battalions are reduced and the army's food drops to 0.
Low-food warnings look ahead to incoming food, upcoming taxes, support, and expected production. If the province has enough food arriving before the next consumption, the warning can account for that.
## Storage Caps
Provinces have separate gold and food caps. These caps are based on effective development and infrastructure:
- Gold cap uses effective economy plus effective infrastructure.
- Food cap uses effective agriculture plus effective infrastructure.
- Both start from a base storage limit of 5000.
- Each point of relevant development or infrastructure adds 50 storage.
- A faction's focus province gets 1.5x storage.
Being over cap is not an immediate hard cutoff. During New Round processing, the province loses 20% of the amount above each cap, rounded up. Excess supplies therefore decay over time unless you spend them, ship them, trade them, or move them.
## Price Index
Price index is local price pressure. It changes costs and trade prices in the province.
The normal range is 0.75 to 1.5. A price index above 1 makes local purchases more expensive; below 1 makes them cheaper.
Gold spending can push the price index upward. Selling food counts as negative gold spending and can push it downward. Each gold spent changes the index by 0.0001, clamped to the normal range.
The price index also drifts each new round toward a local steady state based on development and devastation. It moves 10% of the distance toward that steady state per round.
Commands affected by price index include:
- **Feast** gold cost.
- **Organize Troops** troop costs.
- **Arm Troops** armament costs.
- **Trade** buy and sell prices.
- **Divine** cost.
- **Hero Gift** loyalty gained per gold.
## Devastation
Devastation is damage to province systems. Economy, agriculture, and infrastructure each have their own devastation value.
Devastation reduces the corresponding effective stat. That means:
- Economy devastation reduces gold production and gold storage.
- Agriculture devastation reduces food production and food storage.
- Infrastructure devastation reduces storage, armament ceiling, and infrastructure-dependent checks.
Battles and province events can add devastation. Riots add economy and infrastructure devastation. Blizzards, droughts, floods, epidemics, and battle results can damage different province systems.
Improve with the Devastation option repairs all three devastation tracks. Empty provinces also recover devastation slowly during new round processing.
## Infrastructure and Troops
Infrastructure matters militarily because it caps armament. Arm Troops cannot raise a battalion's ARM above the province's effective infrastructure.
Economy and agriculture also affect which battalion types can be raised in the province. Battalion type availability checks the province's effective economy and effective agriculture against the battalion type's minimums.
Training is handled by [**Train**](/commands/#train). Armament is handled by [**Arm Troops**](/commands/#arm-troops). Organizing troop counts, creating battalions, resizing battalions, and dismissing battalions are handled by [**Organize Troops**](/commands/#organize-troops).
## Heroes and Hero Cap
A province has a hero cap. The default map data sets hero cap from castle count: each castle gives 5 hero capacity.
Going over the cap is allowed, but it creates loyalty pressure. During new round processing, if the province has too many established ruling-faction heroes, those heroes lose loyalty. Newly joined heroes are exempt for their first few rounds.
This is why moving heroes around is not just about command access. Keeping too many heroes stacked in one province can create long-term loyalty damage.
## Ruling Faction and Ruling Hero
A province can have a ruling faction and a ruling hero. The ruling hero is the local leader for the province.
If a faction leader rules the province, the player can command that leader directly. If a [vassal](/heroes/#vassals) rules it, the province acts through vassal behavior during the [Vassal Commands phase](/concepts/eagle-round-flow/#vassal-commands) unless another special phase interrupts it.
Province leadership also matters for riots. The province leader's Charisma affects riot size and gift success, even if another hero is selected to crack down.
## Province Orders
Province orders are standing instructions for vassal-led provinces. The [**Issue Orders**](/commands/#issue-orders) command changes them and can also set or clear the faction focus province.
The main orders are:
- **Entrust:** let the vassal judge the situation.
- **Develop:** build up and improve the province.
- **Mobilize:** prepare military strength and supplies.
- **Expand:** look for growth, movement, and attack opportunities.
The focus province also affects logistics. Vassals may ship excess gold and food toward the focus province if one is set, or toward the nearest faction leader if not.
## Incoming Armies
Incoming armies represent forces that are moving toward the province but have not arrived or resolved yet.
Friendly incoming armies arrive during [Province Move Resolution](/concepts/eagle-round-flow/#province-move-resolution) if the destination is already ruled by their faction. Hostile arrivals do not simply enter the province; they can become hostile army groups and later trigger conquest, attack decisions, defense decisions, battle requests, and [Shardok battles](/concepts/eagle-to-shardok/).
Incoming armies can carry gold and food. If supplies arrive with a friendly movement, transit loss can reduce the amount received.
## Incoming Shipments
Incoming shipments are supplies sent by [**Send Supplies**](/commands/#send-supplies).
The origin province loses the selected gold and food immediately. The destination receives an incoming shipment scheduled for the next round. During the next round's Province Move Resolution phase, the shipment arrives before food consumption. 10% is lost in transit, rounded down after loss.
Because shipments arrive before food consumption, shipping food can prevent starvation in the destination province during the arrival round.
## Province Events
Province events are temporary or local conditions that affect the province.
Important event types include:
- **Riot:** requires a decision if it reaches Handle Riot. If not stopped, it causes support loss and devastation.
- **Beasts:** can damage support over time and can be suppressed for rewards and prestige.
- **Blizzard:** damages province systems and affects supplies.
- **Drought:** damages agriculture.
- **Flood:** damages infrastructure and agriculture.
- **Epidemic:** damages economy, battalions, and hero vigor, and can spread or end over time.
- **Festival:** improves support and prestige while active.
Some events can be created or changed by commands. Mages can use [**Control Weather**](/commands/#control-weather) to start or end blizzards and droughts. Necromancers can use [**Start Epidemic**](/commands/#start-epidemic).
## Tactical Map and Castles
Each province has a `hex_map_name`. When a battle occurs there, Eagle uses that name to choose the Shardok tactical map.
`castle_count` records the province's fortification count. Castles matter for hero capacity, defensive setup, tactical objectives, and some battle behavior.
The source map data in `province_map.tsv` defines province ids, names, neighbor ids, neighbor positions, tactical map names, neighbor names, and castle counts.
@@ -1,452 +0,0 @@
---
title: Shardok Commands
description: Tactical command reference for Shardok battles.
---
<span class="command-reference-marker" aria-hidden="true"></span>
<span class="command-reference-five-row-marker" aria-hidden="true"></span>
<!--
Source map for Shardok command requirements/costs:
- Shared morale/vigor filters: src/main/resources/net/eagle0/shardok/settings.tsv fields minimumMoraleToAct=10 and minimumVigorToAct=10; applied in src/main/cpp/net/eagle0/shardok/library/AvailableCommandsFactory.cpp.
- Fixed AP setting values: src/main/resources/net/eagle0/shardok/settings.tsv. Most command factories call SettingsGetter::ActionCostFor(...) in src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.cpp, which returns ActionCost::usesAll; see src/main/cpp/net/eagle0/shardok/library/ActionCost.hpp and src/main/cpp/net/eagle0/shardok/library/unit/Unit.cpp.
- Melee: meleeActionPointCost=5; src/main/cpp/net/eagle0/shardok/library/command_factories/melee_command_factory/MeleeCommandFactory.cpp.
- Archery: archeryActionPointCost=5 and longbowWindBonusRangeMinSpeed=20; hero archery threshold is agility >= 75 in src/main/cpp/net/eagle0/shardok/ai_battle_simulator/AiBattleSimulator.cpp; normal targets come from TwoAwayTilesUnblockedByMountains in src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.cpp; longbow castle-adjacent and wind-assisted range-3 exceptions are in src/main/cpp/net/eagle0/shardok/library/command_factories/archery_command_factory/ArcheryCommandFactory.cpp; volley spend is in src/main/cpp/net/eagle0/shardok/library/commands/ArcheryCommand.cpp.
- Charge: terrain-entry cost from BattalionType::GetCostToEnterTerrain(...); allowsMoveAfterCharge is true for Light Cavalry and Heavy Cavalry in src/main/resources/net/eagle0/shardok/battalionTypes.tsv; stun settings are baseStunChanceOnCharge and trainingDifferenceMultiplierForStunChanceOnCharge in the same file; src/main/cpp/net/eagle0/shardok/library/command_factories/ChargeCommandFactory.cpp.
- Move: per-hex terrain-entry costs, plus extraCostForZocMovement=1; src/main/cpp/net/eagle0/shardok/library/command_factories/MoveCommandFactory.cpp and src/main/cpp/net/eagle0/shardok/library/commands/MoveCommand.cpp.
- Lightning Bolt: lightningActionPointCost=5 and lightningRange=3; src/main/cpp/net/eagle0/shardok/library/command_factories/LightningBoltCommandFactory.cpp and src/main/cpp/net/eagle0/shardok/library/commands/LightningBoltCommand.cpp.
- Meteor: minimumVigorForMeteor=30, meteorStartActionPointCost=5, meteorTargetActionPointCost=0, meteorCancelActionPointCost=0, meteorCastActionPointCost=1, meteorCastVigorCost=20, meteorRange=3, meteorSplashFactor=0.4, and meteorCastWisdomXp=5; src/main/cpp/net/eagle0/shardok/library/command_factories/MeteorStartCommandFactory.cpp, MeteorTargetCommandFactory.cpp, src/main/cpp/net/eagle0/shardok/library/util/MeteorRangeCoords.cpp, src/main/cpp/net/eagle0/shardok/library/commands/MeteorStartCommand.cpp, MeteorTargetCommand.cpp, MeteorCancelCommand.cpp, and src/main/cpp/net/eagle0/shardok/library/actions/MeteorCastAction.cpp.
- Start Fire: startFireActionPointCost=5; hero start-fire thresholds are Mage or agility >= 70 and wisdom >= 70 in src/main/cpp/net/eagle0/shardok/ai_battle_simulator/AiBattleSimulator.cpp; src/main/cpp/net/eagle0/shardok/library/command_factories/StartFireCommandFactory.cpp and src/main/cpp/net/eagle0/shardok/library/commands/StartFireCommand.cpp.
- Extinguish Fire: extinguishActionPointCost=5; src/main/cpp/net/eagle0/shardok/library/command_factories/ExtinguishFireCommandFactory.cpp and src/main/cpp/net/eagle0/shardok/library/commands/ExtinguishFireCommand.cpp.
- Freeze Water: freezeWaterActionPointCost=5; src/main/cpp/net/eagle0/shardok/library/command_factories/FreezeWaterCommandFactory.cpp.
- Build Bridge: buildBridgeActionPointCost=5; src/main/cpp/net/eagle0/shardok/library/command_factories/BuildBridgeCommandFactory.cpp.
- Blow Bridge: blowBridgeActionPointCost=5; src/main/cpp/net/eagle0/shardok/library/command_factories/BlowBridgeCommandFactory.cpp.
- Placement: no AP cost in setup commands; src/main/cpp/net/eagle0/shardok/library/actions/PlaceUnitCommand.cpp and PlaceHiddenUnitCommand.cpp.
- Scout: scoutActionPointCost=5 and scoutRange=4; src/main/cpp/net/eagle0/shardok/library/command_factories/ScoutCommandFactory.cpp.
- Raise Dead: raiseDeadActionPointCost=5, raiseDeadRange=2, raiseDeadStartingSize=100, raiseDeadWisdomXp=1, and raiseDeadCharismaXp=2; src/main/cpp/net/eagle0/shardok/library/command_factories/RaiseDeadCommandFactory.cpp and src/main/cpp/net/eagle0/shardok/library/commands/RaiseDeadCommand.cpp.
- Dismiss Unit: dismissBaseOdds=60 and requires remainingActionPoints > 0 in src/main/cpp/net/eagle0/shardok/library/command_factories/ControlCommandFactory.cpp; execution zeroes both units in src/main/cpp/net/eagle0/shardok/library/commands/DismissUnitCommand.cpp.
- Holy Wave: holyWaveActionPointCost=5, holyWaveVigorCost=10, holyWaveInspireBonus=25, maxInspireOverBase=10, holyWaveVigorBuf=5, holyWaveDamagePercentage=0.7, and holyWaveCharismaXp=5; src/main/cpp/net/eagle0/shardok/library/command_factories/HolyWaveCommandFactory.cpp and src/main/cpp/net/eagle0/shardok/library/commands/HolyWaveCommand.cpp.
- Stop and Rest: stop posts no AP cost; actionCostToVigorCost=0.5 and rest zeroes AP in src/main/cpp/net/eagle0/shardok/library/commands/UnitRestCommand.cpp; availability and stop implementation are in ChargeCommandFactory.cpp and UnitStopCommand.cpp.
- Challenge Duel: challengeDuelActionPointCost=5, minimumVigorToAcceptDuel=30, duelDeclinedMoraleAdjustment=-15, duelWonMoraleBoost=15, and duelWinnerCharismaXp=20; src/main/cpp/net/eagle0/shardok/library/command_factories/ChallengeDuelCommandFactory.cpp and src/main/cpp/net/eagle0/shardok/library/commands/challenge_duel_command/ChallengeDuelCommand.cpp.
- Fear: fearActionPointCost=5, fearRange=3, fearDebuf=-25, fearFailedBuf=10, and fearCharismaXp=2; src/main/cpp/net/eagle0/shardok/library/command_factories/FearCommandFactory.cpp and src/main/cpp/net/eagle0/shardok/library/commands/FearCommand.cpp.
- Repair: repairActionPointCost=5 and repairBuf=25; src/main/cpp/net/eagle0/shardok/library/command_factories/RepairCommandFactory.cpp and src/main/cpp/net/eagle0/shardok/library/commands/RepairCommand.cpp.
- Reduce: reduceActionPointCost=5, reduceRange=3, and reduceForestBonus=50; src/main/cpp/net/eagle0/shardok/library/command_factories/ReduceCommandFactory.cpp and src/main/cpp/net/eagle0/shardok/library/commands/ReduceCommand.cpp.
- Flee and Retreat: fleeActionPointCost=5 and retreatActionPointCost=5; src/main/cpp/net/eagle0/shardok/library/command_factories/FleeCommandFactory.cpp and RetreatCommandFactory.cpp.
- Control: requires remainingActionPoints > 0 in ControlCommandFactory.cpp; execution zeroes acting unit in src/main/cpp/net/eagle0/shardok/library/commands/ControlCommand.cpp.
- Reinforce: reinforceActionPointCost=5; src/main/cpp/net/eagle0/shardok/library/command_factories/ReinforceCommandFactory.cpp.
- Brave Water: braveWaterActionPointCost=8, braveWaterRangerBonus=20, and braveWaterMaxLoss=0.2; src/main/cpp/net/eagle0/shardok/library/command_factories/BraveWaterCommandFactory.cpp and src/main/cpp/net/eagle0/shardok/library/commands/BraveWaterCommand.cpp.
- Release Unit: requires remainingActionPoints > 0 in ControlCommandFactory.cpp; execution zeroes both units in src/main/cpp/net/eagle0/shardok/library/commands/ReleaseUnitCommand.cpp.
- End Turn and Setup: no command AP cost; end turn zeroes player unit AP in src/main/cpp/net/eagle0/shardok/library/commands/EndTurnCommand.cpp; setup end in src/main/cpp/net/eagle0/shardok/library/actions/EndPlayerSetupCommand.cpp.
- Hide: hideActionPointCost=5; src/main/cpp/net/eagle0/shardok/library/command_factories/HideCommandFactory.cpp.
- Fortify: fortifyActionPointCost=5 and fortifyDamageTakenMultiplier=0.9; src/main/cpp/net/eagle0/shardok/library/command_factories/FortifyCommandFactory.cpp, src/main/cpp/net/eagle0/shardok/library/commands/FortifyCommand.cpp, and the commands/actions that clear fortified status.
- Become Outlaw: uses fleeActionPointCost=5 through src/main/cpp/net/eagle0/shardok/library/command_factories/FleeCommandFactory.cpp and src/main/cpp/net/eagle0/shardok/library/commands/BecomeOutlawCommand.cpp.
- Evacuate Prisoners: evacuatePrisonersActionPointCost=5; src/main/cpp/net/eagle0/shardok/library/command_factories/EvacuatePrisonersCommandFactory.cpp.
-->
## Shared Requirements
Unless noted otherwise: **10 morale**, **10 hero vigor**, and the listed **Action Point cost**.
## Activations and follow-ups
A unit can keep acting while it has Action Points (AP) and at least one available command. Read each command's AP line as follows:
- **Consumes all remaining AP** or **sets the unit to 0 AP** means **ends activation**. The unit cannot act again this turn.
- A command with only an exact AP cost spends that amount. If the unit still has AP afterward, its activation can continue with whatever commands are then available.
- A command may also end the unit's useful activation because no legal commands remain, even if the display still shows AP.
Movement is the most important case. Each destination shows its movement cost and may show **follow-up available** icons. Those follow-ups are actions predicted to be legal *after reaching that destination*, such as Charge, Scout, Start Fire, Repair, Reduce, or Reinforce.
The follow-up list is a planning aid, not a complete list of everything the unit might do next. It does not include another Move, and battlefield information can change when the move resolves. A destination with no follow-up icons does not itself mean “ends activation”: compare the movement cost with the unit's remaining AP and check the commands offered after arrival.
## Tactical Commands
### Melee
<b>Unit requirements:</b> any unit.
<b>Action Point cost:</b> 5 AP; consumes all remaining AP.
<b>Available when:</b> an enemy target is adjacent and the acting unit can still act.
<b>Effect:</b> Attack the target in close combat. The UI may show combat odds before you choose the attack.
<b>Selection:</b> choose the attacking unit and adjacent target.
### Archery
<b>Unit requirements:</b> Longbowmen, or a unit with an attached hero who has at least 75 AGI. The unit must have at least one volley remaining.
<b>Action Point cost:</b> 5 AP; consumes all remaining AP.
<b>Available when:</b> a known enemy is exactly 2 hexes away, and the line between the attacker and target is not blocked by mountains.
<b>Effect:</b> Make a ranged attack without requiring adjacency, spending one volley. Longbowmen can also fire at adjacent hexes while standing in a castle, and can fire 3 hexes when the wind is at least 20 mph and blowing toward the target.
<b>Selection:</b> choose the firing unit and target unit or hex.
### Charge
<b>Unit requirements:</b> any unit.
<b>Action Point cost:</b> the cost to enter the target's terrain. Knights and Dragoons spend only that terrain cost and may be able to move again afterward; other units spend all remaining AP.
<b>Activation:</b> ends activation for units that spend all remaining AP. Knights and Dragoons can continue when they retain AP and another command is available.
<b>Available when:</b> a valid path reaches a target that can be attacked at the end of the move.
<b>Effect:</b> Move into an attack as one action. Charge attacks have a chance to stun the target unit.
<b>Selection:</b> choose the charging unit, path, and target.
### Move
<b>Unit requirements:</b> any unit.
<b>Action Point cost:</b> the terrain cost for each hex in the path, plus 1 extra AP for steps that start in enemy zone of control.
<b>Activation:</b> does not inherently end activation. The unit can continue if it has AP and a legal command after arriving. Follow-up icons on a destination preview eligible non-move actions there.
<b>Available when:</b> one or more reachable destination hexes are valid for the acting unit.
<b>Effect:</b> Move the unit along the chosen path to a reachable hex, paying terrain costs as it goes. Hidden units may reveal themselves if they move into exposed terrain or near an enemy Ranger.
<b>Selection:</b> choose the unit and destination path.
### Lightning Bolt
<b>Unit requirements:</b> [Mage](/heroes/#mage) attached to Light Infantry, Light Cavalry, or Longbowmen. Heavy units and Undead cannot cast.
<b>Action Point cost:</b> 5 AP; consumes all remaining AP.
<b>Available when:</b> a known enemy is within 3 hexes of the caster.
<b>Effect:</b> Cast a lightning attack at the target. The descriptor may include odds or roll information when the client can display expected results.
<b>Selection:</b> choose the casting unit and target.
### Meteor
<b>Unit requirements:</b> [Mage](/heroes/#mage) attached to Light Infantry, Light Cavalry, or Longbowmen, with at least 30 vigor. Heavy units and Undead cannot cast.
<b>Action Point cost:</b> start costs 5 AP and consumes all remaining AP; target and cancel cost 0 AP; the automatic cast step is configured as 1 AP but resolves by setting the caster to 0 AP.
<b>Available when:</b> the caster can begin the spell, choose or change a target within 3 hexes, cast the pending meteor, or cancel the pending meteor.
<b>Effect:</b> Begin a delayed meteor spell, then choose a target within 3 hexes on the following round. When the meteor resolves, a unit on the target hex takes a fire attack based on the Mage's Wisdom. Units on the six adjacent hexes take 40% as much. The impact damages castles, bridges, ice, and snow; may start fires on the target and adjacent hexes; and removes fortification from units it hits. Destroying supporting ice or a bridge can also drop a unit into the water. The Mage spends 20 vigor, gains 5 Wisdom XP, and is stunned for 2 rounds after the strike. You can cancel while the meteor is still pending.
<b>Selection:</b> choose the caster, spell target, or cancel option depending on the current meteor step.
### Start Fire
<b>Unit requirements:</b> a unit with an attached [Mage](/heroes/#mage), or an attached hero with at least 70 AGI and 70 WIS. Mages improve the odds.
<b>Action Point cost:</b> 5 AP; consumes all remaining AP.
<b>Available when:</b> a target hex can be set on fire.
<b>Effect:</b> Attempt to start a fire on the target hex.
<b>Selection:</b> choose the acting unit and target hex.
### Extinguish Fire
<b>Unit requirements:</b> any unit with an attached hero.
<b>Action Point cost:</b> 5 AP; consumes all remaining AP.
<b>Available when:</b> a target hex contains fire that can be reduced or removed.
<b>Effect:</b> Remove or reduce fire on the target hex.
<b>Selection:</b> choose the acting unit and burning target hex.
### Freeze Water
<b>Unit requirements:</b> [Mage](/heroes/#mage) attached to Light Infantry, Light Cavalry, or Longbowmen. Heavy units and Undead cannot cast.
<b>Action Point cost:</b> 5 AP; consumes all remaining AP.
<b>Available when:</b> a valid water hex can be frozen.
<b>Effect:</b> Turn water into traversable frozen terrain where the command is valid.
<b>Selection:</b> choose the caster and water hex.
### Build Bridge
<b>Unit requirements:</b> [Engineer](/heroes/#engineer).
<b>Action Point cost:</b> 5 AP; consumes all remaining AP.
<b>Available when:</b> the engineer has a valid water or gap target where a bridge can be built.
<b>Effect:</b> Create a bridge across the target.
<b>Selection:</b> choose the engineer unit and bridge target.
### Blow Bridge
<b>Unit requirements:</b> [Engineer](/heroes/#engineer) standing on a bridge.
<b>Action Point cost:</b> 5 AP; consumes all remaining AP.
<b>Available when:</b> the bridge can be destroyed.
<b>Effect:</b> Destroy the existing bridge target.
<b>Selection:</b> choose the engineer unit and bridge.
### Placement
<b>Unit requirements:</b> a unit available for setup placement. Hidden placement requires a defending [Ranger](/heroes/#ranger) attached to a stealth-capable unit.
<b>Action Point cost:</b> no AP cost during setup.
<b>Available when:</b> the battle is in setup and a valid placement hex is available.
<b>Effect:</b> Put a reserve unit onto the battlefield. Rangers on stealth-capable units can be placed hidden during defensive setup.
<b>Selection:</b> choose the unit and setup hex.
### Scout
<b>Unit requirements:</b> [Ranger](/heroes/#ranger) attached to a stealth-capable unit.
<b>Action Point cost:</b> 5 AP; consumes all remaining AP.
<b>Available when:</b> the ranger can scout a hex within 4 hexes.
<b>Effect:</b> Reveal information about the target hex and nearby terrain, including possible hidden-unit information.
<b>Selection:</b> choose the scouting unit and target.
### Raise Dead
<b>Unit requirements:</b> [Necromancer](/heroes/#necromancer) attached to Light Infantry, Light Cavalry, or Longbowmen, and the hero must not already control a unit. Heavy units and Undead cannot cast.
<b>Action Point cost:</b> 5 AP; consumes all remaining AP.
<b>Available when:</b> there is an empty, undead-traversable target hex within 2 hexes.
<b>Effect:</b> Make the displayed success roll. On success, create a battalion of 100 Undead on the target hex, controlled by the Necromancer. The new battalion starts with 50 morale, 0 training, and 0 armament. On failure, no unit is created. Either result spends the action; the Necromancer gains 1 Wisdom XP and 2 Charisma XP.
<b>Selection:</b> choose the necromancer unit and target.
### Dismiss Unit
<b>Unit requirements:</b> a hero who currently controls a summoned unit, such as Undead raised by a Necromancer.
<b>Action Point cost:</b> requires the controlling unit to have more than 0 AP; execution sets both the controlling unit and dismissed unit to 0 AP.
<b>Available when:</b> the controlling hero has more than 0 AP and still has a controlled unit.
<b>Effect:</b> Make the displayed success roll, based on the controlling hero's Charisma and Wisdom. Success disbands the controlled unit. Failure still ends the control relationship, but the unit remains on the battlefield uncontrolled and targets its former controller.
<b>Selection:</b> choose Dismiss Unit on the controlling hero; the controlled unit is the target.
### Holy Wave
<b>Unit requirements:</b> [Paladin](/heroes/#paladin).
<b>Action Point cost:</b> 5 AP; consumes all remaining AP.
<b>Vigor cost:</b> 10 from the Paladin under normal circumstances.
<b>Available when:</b> the Paladin unit has enough AP and meets the shared morale and hero-vigor requirements.
<b>Effect:</b> Emit a wave centered on the Paladin. The Paladin and every adjacent non-Undead unit controlled by the same player gain 25 morale, capped at 10 above base morale and at 100 overall; attached heroes regain 5 vigor up to starting vigor; and stun is removed. Every adjacent Undead unit, regardless of allegiance, loses 70% of its troops, becomes visible, and targets the Paladin. The Paladin gains 5 Charisma XP. The effect does not make a success roll.
<b>Selection:</b> choose the Paladin unit. Holy Wave does not require a target.
### Stop and Rest
<b>Unit requirements:</b> a unit with an available charge.
<b>Action Point cost:</b> Stop costs 0 AP; Rest consumes all remaining AP.
<b>Available when:</b> Stop is offered outside an enemy zone of control; Rest is offered inside an enemy zone of control.
<b>Effect:</b> Stop declines the available charge without spending the unit's remaining AP. Rest declines the charge, ends the activation, and restores an attached hero's vigor by half of the AP given up. For example, resting with 4 AP restores 2 vigor.
<b>Selection:</b> choose stop or rest for the acting unit.
### Challenge Duel
<b>Unit requirements:</b> [Champion](/heroes/#champion) challenging an adjacent known enemy unit with an attached hero. A challenge cannot enter a castle.
<b>Action Point cost:</b> 5 AP; consumes all remaining AP.
<b>Available when:</b> the target hero has at least 30 vigor, or the Champion does not know enough about the target to know otherwise.
<b>Effect:</b> The target's chance to accept depends on Bravery and its assessment of the two battalions and heroes. A target that declines normally loses 15 morale; a target known to have less than 30 vigor declines without that penalty. If accepted, the heroes exchange attacks until one falls or 30 exchanges pass. A winner's battalion gains 15 morale and the winning hero gains 20 Charisma XP. Participating heroes can also gain Strength and Agility XP.
<b>Selection:</b> choose the champion unit and duel target.
### Fear
<b>Unit requirements:</b> [Necromancer](/heroes/#necromancer) attached to Light Infantry, Light Cavalry, or Longbowmen. Heavy units and Undead cannot cast.
<b>Action Point cost:</b> 5 AP; consumes all remaining AP.
<b>Available when:</b> a known enemy that uses morale is within 3 hexes.
<b>Effect:</b> Make the displayed success roll. The Necromancer's Charisma and Wisdom improve the chance, while a target hero's Charisma resists it. Success reduces the target battalion's morale by 25 and stuns it for 1 round. Failure instead increases its morale by 10. The Necromancer gains 2 Charisma XP either way.
<b>Selection:</b> choose the necromancer unit and target.
### Repair
<b>Unit requirements:</b> [Engineer](/heroes/#engineer).
<b>Action Point cost:</b> 5 AP; consumes all remaining AP.
<b>Available when:</b> a damaged friendly or unoccupied bridge or castle is on the Engineer's hex or an adjacent hex. The Engineer cannot repair from water or repair a structure occupied by a known enemy.
<b>Effect:</b> Restore 25 integrity to the bridge or castle, up to its maximum of 100. Repair does not restore troops or hero vigor.
<b>Selection:</b> choose the Engineer unit and the bridge or castle to repair.
### Reduce
<b>Unit requirements:</b> [Engineer](/heroes/#engineer) in a castle or fortified position.
<b>Action Point cost:</b> 5 AP; consumes all remaining AP.
<b>Available when:</b> a known enemy unit or a castle with remaining integrity is within 3 hexes and the line is not blocked by mountains. Friendly-occupied hexes and the Engineer's own hex cannot be targeted.
<b>Effect:</b> Bombard the target. The damage roll improves with the Engineer's Agility, Strength, and Wisdom, and gains a bonus when friendly forces have access to a forest. The result reduces castle or bridge integrity on the hex, damages any occupying enemy unit, and removes that unit's fortified status.
<b>Selection:</b> choose the engineer unit and target.
### Flee
<b>Unit requirements:</b> a unit with an attached hero that is allowed to flee.
<b>Action Point cost:</b> 5 AP; consumes all remaining AP.
<b>Available when:</b> the hero can leave the battle and has enough AP.
<b>Effect:</b> Attempt to withdraw from the province entirely. Make the displayed success roll: nearby visible friendly units improve the chance to escape, while nearby visible enemies and impassable terrain reduce it. Units 2 hexes away have half as much effect as adjacent units. VIP heroes escape automatically. On success, the unit leaves both the battle and the province and will escape even if the province is later captured. On failure, the unit is captured during the attempt.
<b>Selection:</b> choose the fleeing unit.
### Retreat
<b>Unit requirements:</b> a defending unit with an attached hero.
<b>Action Point cost:</b> 5 AP; consumes all remaining AP.
<b>Available when:</b> the defending hero has enough AP. Attackers cannot use Retreat.
<b>Effect:</b> Withdraw the unit from the battle while remaining in the province. Retreat succeeds automatically and does not make the immediate capture roll used by Flee, but the hero will still be captured if their side loses the battle and the province is captured.
<b>Selection:</b> choose the retreating unit.
### Control
<b>Unit requirements:</b> a hero who already has a controlled summoned unit, such as Undead raised by a Necromancer.
<b>Action Point cost:</b> requires more than 0 AP; execution sets the acting unit to 0 AP.
<b>Available when:</b> the controlling hero has more than 0 AP and still has a controlled unit.
<b>Effect:</b> Spend the controlling hero's remaining AP to command the linked unit this round. Control does not create a new controlled unit; Raise Dead establishes that relationship.
<b>Selection:</b> choose Control on the controlling hero; the linked unit is the target.
### Reinforce
<b>Unit requirements:</b> an on-map unit with enough AP and a friendly reserve unit available to enter the battle.
<b>Action Point cost:</b> 5 AP; consumes all remaining AP.
<b>Available when:</b> fewer than 10 friendly on-map units have attached heroes, and the reserve unit has an empty, non-burning hex in its designated starting area.
<b>Effect:</b> Spend the acting unit's activation and move the selected reserve unit onto the chosen entry hex as a normal active unit.
<b>Selection:</b> choose the reinforcement option and entry placement.
### Brave Water
<b>Unit requirements:</b> a unit with an attached hero whose battalion type permits Brave Water. The hero needs at least 10 vigor. [Ranger](/heroes/#ranger) is not required, but Rangers receive a 20-point odds bonus.
<b>Action Point cost:</b> 8 AP; consumes all remaining AP.
<b>Available when:</b> the unit is on land and can reach a believed-empty, traversable land hex across water.
<b>Effect:</b> Make the displayed crossing roll. Agility and Wisdom improve the chance; armament and bad weather reduce it. Success moves the unit across the water, unless a hidden occupant triggers an ambush on arrival. Failure leaves the unit in place. The attempt always stuns the unit for 1 round and can cost up to 20% of its troops, armament, and hero vigor; safer crossings reduce the possible losses.
<b>Selection:</b> choose the unit and water movement target.
### Release Unit
<b>Unit requirements:</b> a hero who currently controls a summoned unit, such as Undead raised by a Necromancer.
<b>Action Point cost:</b> requires more than 0 AP; execution sets both the controlling unit and released unit to 0 AP.
<b>Available when:</b> the controlling hero has more than 0 AP and still has a controlled unit.
<b>Effect:</b> End the control relationship without a roll. The released unit becomes uncontrolled, targets its former controller, and remains on the battlefield. Both units end their activations.
<b>Selection:</b> choose Release Unit on the controlling hero; the controlled unit is the target.
### End Turn and Setup
<b>Unit requirements:</b> None.
<b>Action Point cost:</b> no AP cost to post the command; End Turn then sets that player's remaining unit AP to 0.
<b>Available when:</b> the active player can end the tactical turn or finish setup.
<b>Effect:</b> `END_TURN_COMMAND` ends the active tactical turn. `END_PLAYER_SETUP_COMMAND` ends that player's setup phase.
<b>Selection:</b> choose the end-turn or end-setup command.
### Hide
<b>Unit requirements:</b> [Ranger](/heroes/#ranger) attached to a stealth-capable unit.
<b>Action Point cost:</b> 5 AP; consumes all remaining AP.
<b>Available when:</b> the unit can enter or remain in hidden status.
<b>Effect:</b> Hide the unit.
<b>Selection:</b> choose the ranger unit.
### Fortify
<b>Unit requirements:</b> [Engineer](/heroes/#engineer).
<b>Action Point cost:</b> 5 AP; consumes all remaining AP.
<b>Available when:</b> the Engineer is not already fortified, is standing on fortifiable terrain, and has enough AP.
<b>Effect:</b> Mark the unit as fortified, reducing damage taken by 10%. Moving removes fortification; being hit by Reduce or Meteor also removes it, as does taking fire damage at the end of the unit's turn.
<b>Selection:</b> choose the engineer unit.
### Become Outlaw
<b>Unit requirements:</b> a unit with an attached hero that is not allowed to flee normally.
<b>Action Point cost:</b> 5 AP through the flee/outlaw action.
<b>Available when:</b> the hero has enough AP, cannot flee, and the battle permits the outlaw outcome.
<b>Effect:</b> Use the same displayed escape roll as Flee. Success removes the unit from battle and records the hero as an outlaw. Failure removes the unit as captured.
<b>Selection:</b> choose the outlaw command when offered.
### Evacuate Prisoners
<b>Unit requirements:</b> [Warden](/heroes/#warden) on a unit that can flee.
<b>Action Point cost:</b> 5 AP; consumes all remaining AP.
<b>Available when:</b> at least one hostile hero has already been captured, and the Warden is allowed to flee and has enough AP.
<b>Effect:</b> Remove the Warden's unit from battle as evacuated and secure every captured hostile hero for the Warden's player. The evacuation succeeds automatically.
<b>Selection:</b> choose the warden unit and evacuation command.

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