Compare commits

..
Author SHA1 Message Date
adminandClaude e2f7b82ecd Eliminate expensive proto conversions in ShardokAction
Refactored ShardokAction to store a pointer to ShardokCommand instead of
eagerly converting to CommandProto. This eliminates millions of expensive
GetCommandProto() calls during MCTS search.

Key changes:
- Added forward declaration for ShardokCommand in ShardokAction.hpp
- New constructor: ShardokAction(const ShardokCommand* command, size_t index)
- Changed internal storage: const ShardokCommand* command_ (non-owning pointer)
- Made getCommand() lazy: only converts Command→Proto when actually needed
- Updated clone() to copy command pointer (cheap) instead of proto
- Updated BUILD.bazel visibility for shardok_command target

Performance impact: 2.1x speedup (870K → 1.8M visits)
Combined with previous optimizations: 55x total speedup (33K → 1.8M visits)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 19:10:07 -07:00
adminandClaude 4214610f33 Cache action objects to reduce allocation overhead
Caches the actual MCTSAction objects alongside legal actions data,
allowing fast cloning instead of reconstructing from command protos.

Changes:
- Added cachedActions vector to LegalActionsCache struct
- Modified getLegalActions() to clone cached actions when available
- Store cloned actions in cache after computing them

Impact:
- Reduces repeated action construction from cached data
- Performance similar to previous (~750K visits)
- Sets stage for eliminating expensive proto conversions

Note: This shows diminishing returns, suggesting proto conversion
is now the bottleneck (GetCommandProto() called on every action creation).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 18:59:32 -07:00
adminandClaude 339b2e8e63 Cache action weights to eliminate getActionWeights bottleneck
After adding transition caching, getActionWeights became the dominant
cost at 61% of runtime. This caches action weights alongside legal actions.

Changes:
- Added actionWeights vector to LegalActionsCache struct
- Modified getActionWeights() to:
  - Check cache for weights based on state hash
  - Return cached weights if available and size matches
  - Compute and store weights on cache miss

Performance impact:
- Additional 1.7x speedup on top of transition caching
- Total improvement: ~26x vs original (33K → 870K visits/search)
- Eliminates redundant AIHeuristicWeighting calculations for revisited states

The weights are deterministic for a given (state, actions) pair, making
them safe to cache. Since we already cache legal actions per state hash,
adding weights to the same cache entry is straightforward and effective.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 18:53:05 -07:00
adminandClaude cfe33bd7e4 Fix transition cache: store next state's engine in cache
CRITICAL FIX: The previous implementation stored transition mappings
(currentState, action) -> nextStateHash, but never stored nextStateHash's
engine in the cache. This caused 100% cache miss rate because lookups
for nextStateHash would always fail.

Changes:
- In applyAction(): After storing transition, also store next state's engine
  in legalActionsCache_[nextStateHash].engine
- In applyActionMutable(): Same fix

Why this matters:
Without this, the transition cache lookup chain would fail at:
1. Find transition: (stateA, action) -> stateB hash ✓
2. Look up stateB in cache to get engine ✗ (not in cache!)

Now stateB's engine is cached immediately when the transition is stored,
so future lookups of the same transition can retrieve the complete state.

Note: Statistics appear low/zero in test output because they're thread-local
and we only report from the main thread. Worker threads that do MCTS
simulations have separate caches. This doesn't affect correctness, only
visibility of metrics.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 18:43:34 -07:00
adminandClaude 6384419c51 Add transition caching to MCTS ShardokGameEngine
Implements state transition caching to avoid redundant PostCommand calls
during MCTS tree search. Key changes:

1. Added TransitionKey struct to cache (actionIndex, roll) tuples
2. Extended LegalActionsCache with actionResults map for transition storage
3. Modified applyAction() and applyActionMutable() to:
   - Check transition cache before applying actions
   - Store new transitions after PostCommand
4. Added thread-local statistics for transition cache hits/misses
5. Updated reportCacheStatistics() to display transition cache metrics
6. Use SequenceRandomGenerator with {0.5} for deterministic 50th percentile rolls
   (matching IterativeDeepening AI behavior)

Benefits:
- Eliminates redundant PostCommand calls for previously-seen state transitions
- Particularly valuable for MCTS which revisits states many times
- Cache is thread-local to avoid lock contention in multithreaded simulation
- Complements existing legal actions cache for comprehensive optimization

Testing:
- All MCTS-specific tests pass (ai_mcts_test, shardok_mcts_ai_basic_test)
- 12 of 13 integration tests pass
- Transition cache statistics visible in search output

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 18:03:58 -07:00
1457 changed files with 100914 additions and 123161 deletions
+2 -9
View File
@@ -26,17 +26,10 @@ common --host_cxxopt="--std=c++23"
common --javacopt="-Xlint:-options"
# suppress warnings due to https://developer.apple.com/forums/thread/733317
# Use host_linkopt for macOS-specific flags to avoid passing them to Linux cross-compilation
common:macos --host_linkopt=-Wl,-no_warn_duplicate_libraries
# Fix Xcode version caching issue - avoids need for `bazel clean --expunge` after Xcode updates
common:macos --repo_env=DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
common --linkopt=-Wl
common:macos --linkopt=-Wl,-no_warn_duplicate_libraries
common --java_language_version=17
common --java_runtime_version=remotejdk_17
common --tool_java_language_version=17
common --tool_java_runtime_version=remotejdk_17
# Workspace status for build stamping (git commit, timestamp)
common --workspace_status_command=tools/workspace_status.sh
common --stamp
-3
View File
@@ -6,7 +6,4 @@
*.bytes filter=lfs diff=lfs merge=lfs -text
*.psd filter=lfs diff=lfs merge=lfs -text
*.ttf filter=lfs diff=lfs merge=lfs -text
# Exclude pre-existing font files that were committed as blobs (not LFS pointers)
src/main/csharp/**/GUI[[:space:]]Pro[[:space:]]Kit*/**/*.ttf !filter !diff !merge
src/main/csharp/**/Modern[[:space:]]UI[[:space:]]Pack/**/*.ttf !filter !diff !merge
*.herodata filter=lfs diff=lfs merge=lfs -text
-179
View File
@@ -1,179 +0,0 @@
name: Auth Service Build and Deploy
on:
push:
branches: [ "main" ]
paths:
- 'src/main/go/net/eagle0/authservice/**'
- 'src/main/go/net/eagle0/authcli/**'
- 'src/main/protobuf/net/eagle0/eagle/api/auth.proto'
- 'src/main/protobuf/net/eagle0/eagle/api/admin/**'
- 'src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto'
- 'ci/BUILD.bazel'
- '.github/workflows/auth_build.yml'
workflow_dispatch:
inputs:
push_images:
description: 'Push images to container registry'
required: true
default: 'false'
type: boolean
permissions:
contents: read
jobs:
build-auth:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-auth.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Auth Server Docker image
id: build-auth
run: |
set -ex
# Build auth server image (Go binary has explicit goos/goarch in BUILD.bazel)
bazel build //ci:auth_server_image
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/auth_server_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push Auth image to DO registry
id: push-auth
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
AUTH_IMAGE="${{ steps.build-auth.outputs.image_path }}"
echo "Using Auth image: $AUTH_IMAGE"
if [ -z "$AUTH_IMAGE" ] || [ ! -d "$AUTH_IMAGE" ]; then
echo "ERROR: Auth image not found at: $AUTH_IMAGE"
exit 1
fi
# Build the push target to get crane in runfiles
bazel build //ci:auth_server_push
# Use crane directly for push
CRANE="bazel-bin/ci/push_auth_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
echo "Using crane: $CRANE"
# Push with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/auth-server:${GIT_SHA}"
echo "Pushing auth image: $IMAGE_TAG"
$CRANE push "$AUTH_IMAGE" "$IMAGE_TAG"
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/auth-server:latest"
deploy:
runs-on: ubuntu-latest
needs: [build-auth]
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
environment: production
env:
AUTH_IMAGE: ${{ needs.build-auth.outputs.image_tag }}
DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }}
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
FASTMAIL_API_TOKEN: ${{ secrets.FASTMAIL_API_TOKEN }}
FASTMAIL_FROM_EMAIL: ${{ secrets.FASTMAIL_FROM_EMAIL }}
FASTMAIL_FROM_NAME: ${{ secrets.FASTMAIL_FROM_NAME }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Copy update-env script to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
source: "deploy/update-env.sh,deploy/env.template"
target: /opt/eagle0/
strip_components: 1
- name: Deploy auth service to production
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
script_stop: true
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,JWT_PRIVATE_KEY,FASTMAIL_API_TOKEN,FASTMAIL_FROM_EMAIL,FASTMAIL_FROM_NAME
script: |
set -x
cd /opt/eagle0
# Update env vars using shared script (preserves vars set by other workflows)
chmod +x update-env.sh
./update-env.sh \
"AUTH_IMAGE=${AUTH_IMAGE}" \
"DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}" \
"DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}" \
"GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}" \
"GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}" \
"JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}" \
"FASTMAIL_API_TOKEN=${FASTMAIL_API_TOKEN}" \
"FASTMAIL_FROM_EMAIL=${FASTMAIL_FROM_EMAIL}" \
"FASTMAIL_FROM_NAME=${FASTMAIL_FROM_NAME}"
# Login to registry
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
echo "Deploying auth service: $AUTH_IMAGE"
# Use crane to pull image
echo "Installing crane..."
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
chmod +x crane
echo "Pulling Auth image with crane..."
./crane pull "${AUTH_IMAGE}" auth.tar || { echo "ERROR: Failed to pull auth image"; exit 1; }
echo "Loading Auth image into Docker..."
docker load -i auth.tar
rm auth.tar
rm ./crane
# Only recreate the auth container (not eagle, shardok, etc.)
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate auth
# Wait for health check
sleep 5
docker compose -f docker-compose.prod.yml ps auth
# Verify container is using correct image
echo "=== Verifying auth container image ==="
docker compose -f docker-compose.prod.yml images auth
# Cleanup old images
docker image prune -f
+1 -45
View File
@@ -34,54 +34,10 @@ jobs:
with:
lfs: false
- name: Run tests
id: test
continue-on-error: true
run: bazel test --build_event_json_file=test.json //src/test/... //src/main/go/...
- name: Collect failed test logs
if: always()
run: |
# Remove any existing failed_test_logs directory and create fresh
rm -rf failed_test_logs
mkdir -p failed_test_logs
# Extract failed test targets from test.json and copy their logs
# The test.json is in JSONL format - one JSON object per line
# We look for lines with testResult that have a status other than PASSED
if [ -f test.json ]; then
grep '"testResult"' test.json | \
grep '"status"' | \
grep -v '"status":"PASSED"' | \
grep -o '"label":"[^"]*"' | \
cut -d'"' -f4 | \
sort -u | \
while read target; do
# Convert target like //src/test/cpp/...:test_name to path
log_path=$(echo "$target" | sed 's|^//||' | sed 's|:|/|')
if [ -f "bazel-testlogs/$log_path/test.log" ]; then
log_name=$(echo "$log_path" | tr '/' '_')
if cp "bazel-testlogs/$log_path/test.log" "failed_test_logs/${log_name}.log"; then
echo "Collected log for failed test: $target"
else
echo "Error: Failed to copy log for $target"
fi
fi
done
fi
# List what we collected
echo "Collected logs:"
ls -lh failed_test_logs/ 2>/dev/null || echo "No logs collected"
- name: Archive test results
if: always()
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: test.json
path: test.json
- name: Archive failed test logs
if: always()
uses: actions/upload-artifact@v4
with:
name: failed-test-logs
path: failed_test_logs/
if-no-files-found: ignore
- name: Fail if tests failed
if: steps.test.outcome == 'failure'
run: exit 1
-137
View File
@@ -1,137 +0,0 @@
name: Build Linux Sysroot
on:
workflow_dispatch:
inputs:
version:
description: 'Sysroot version (e.g., v2, v3)'
required: true
default: 'v2'
type: string
architecture:
description: 'Target architecture'
required: true
default: 'amd64'
type: choice
options:
- amd64
- arm64
- both
permissions:
contents: read
jobs:
build-sysroot-amd64:
if: ${{ inputs.architecture == 'amd64' || inputs.architecture == 'both' }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build sysroot
run: ./tools/sysroot/build_sysroot.sh
- name: Upload sysroot artifact
uses: actions/upload-artifact@v4
with:
name: ubuntu-noble-sysroot-amd64
path: tools/sysroot/output/
- name: Install AWS CLI
run: |
if ! command -v aws &> /dev/null; then
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip -q awscliv2.zip
sudo ./aws/install
fi
- name: Upload to DigitalOcean Spaces
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SECRET_KEY }}
run: |
# Upload sysroot tarball to DO Spaces
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.tar.xz \
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
# Upload sha256 file
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256 \
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.sha256 \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
echo ""
echo "=== AMD64 Sysroot uploaded ==="
echo "URL: https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz"
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)"
echo ""
echo "Update MODULE.bazel with:"
echo "sysroot("
echo " name = \"linux_sysroot\","
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)\","
echo " urls = [\"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
echo ")"
build-sysroot-arm64:
if: ${{ inputs.architecture == 'arm64' || inputs.architecture == 'both' }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU for ARM64 emulation
uses: docker/setup-qemu-action@v3
with:
platforms: arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build ARM64 sysroot
run: ./tools/sysroot/build_sysroot_arm64.sh
- name: Upload sysroot artifact
uses: actions/upload-artifact@v4
with:
name: ubuntu-noble-sysroot-arm64
path: tools/sysroot/output/
- 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 ")"
-342
View File
@@ -1,342 +0,0 @@
name: Docker Build and Push
on:
push:
branches: [ "main" ]
paths:
- 'src/main/cpp/**'
- 'src/main/go/**'
- 'src/main/scala/**'
- 'src/main/protobuf/**'
- 'src/main/resources/**'
- 'ci/BUILD.bazel'
- 'MODULE.bazel'
- 'docker-compose.prod.yml'
- 'nginx/**'
- '.github/workflows/docker_build.yml'
workflow_dispatch:
inputs:
push_images:
description: 'Push images to container registry'
required: true
default: 'false'
type: boolean
# Only allow one deployment at a time to prevent race conditions
concurrency:
group: docker-build-deploy
cancel-in-progress: false # Don't cancel running deployments, queue new ones
permissions:
contents: read
jobs:
# Single consolidated build job - builds all images with one bazel invocation
# This uses 1 runner slot instead of 4, and Bazel parallelizes internally
build-all:
runs-on: self-hosted
outputs:
eagle_image_tag: ${{ steps.push-images.outputs.eagle_image_tag }}
shardok_image_tag: ${{ steps.push-images.outputs.shardok_image_tag }}
admin_image_tag: ${{ steps.push-images.outputs.admin_image_tag }}
jfr_sidecar_image_tag: ${{ steps.push-images.outputs.jfr_sidecar_image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build all Docker images
id: build-all
run: |
set -ex
# Build ALL images in a single bazel command - Bazel parallelizes internally
# This is much more efficient than 4 separate GitHub Actions jobs
echo "=== Building all Docker images ==="
bazel build \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:all \
//ci:eagle_server_image \
//ci:shardok_server_image \
//ci:admin_server_image \
//ci:jfr_sidecar_image \
//src/main/go/net/eagle0/warmup:warmup_linux_amd64
# Copy warmup binary to scripts/ for deployment
mkdir -p scripts/bin
cp bazel-bin/src/main/go/net/eagle0/warmup/warmup_linux_amd64_/warmup_linux_amd64 scripts/bin/warmup
# Save all image paths before any other bazel command changes bazel-bin symlink
EAGLE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
SHARDOK_PATH=$(readlink -f bazel-bin/ci/shardok_server_image)
ADMIN_PATH=$(readlink -f bazel-bin/ci/admin_server_image)
JFR_PATH=$(readlink -f bazel-bin/ci/jfr_sidecar_image)
echo "eagle_path=$EAGLE_PATH" >> $GITHUB_OUTPUT
echo "shardok_path=$SHARDOK_PATH" >> $GITHUB_OUTPUT
echo "admin_path=$ADMIN_PATH" >> $GITHUB_OUTPUT
echo "jfr_path=$JFR_PATH" >> $GITHUB_OUTPUT
echo "=== Image paths ==="
echo "Eagle: $EAGLE_PATH"
echo "Shardok: $SHARDOK_PATH"
echo "Admin: $ADMIN_PATH"
echo "JFR Sidecar: $JFR_PATH"
# Verify Shardok binary is ELF (Linux) format
echo "=== Verifying Shardok binary format ==="
BINARY_TAR="bazel-bin/ci/shardok_binary_layer.tar"
if [ -f "$BINARY_TAR" ]; then
MAGIC=$(tar -xOf "$BINARY_TAR" app/shardok-server 2>/dev/null | head -c 4 | xxd -p)
echo "Binary magic in tar: $MAGIC"
if [ "$MAGIC" = "7f454c46" ]; then
echo "SUCCESS: Binary is ELF format (Linux)"
else
echo "ERROR: Binary is NOT ELF format!"
exit 1
fi
fi
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push all images to DO registry
id: push-images
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
GIT_SHA=$(git rev-parse --short=8 HEAD)
# Get crane from push target runfiles
bazel build //ci:eagle_server_push
CRANE="bazel-bin/ci/push_eagle_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
if [ ! -e "$CRANE" ]; then
# Fallback: find any Darwin crane
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
fi
if [ -z "$CRANE" ] || [ ! -e "$CRANE" ]; then
echo "ERROR: crane not found"
exit 1
fi
echo "Using crane: $CRANE"
# Push Eagle image
EAGLE_IMAGE="${{ steps.build-all.outputs.eagle_path }}"
EAGLE_TAG="registry.digitalocean.com/eagle0/eagle-server:${GIT_SHA}"
echo "Pushing Eagle: $EAGLE_TAG"
$CRANE push "$EAGLE_IMAGE" "$EAGLE_TAG"
$CRANE copy "$EAGLE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest"
echo "eagle_image_tag=$EAGLE_TAG" >> $GITHUB_OUTPUT
# Push Shardok image
SHARDOK_IMAGE="${{ steps.build-all.outputs.shardok_path }}"
SHARDOK_TAG="registry.digitalocean.com/eagle0/shardok-server:${GIT_SHA}"
echo "Pushing Shardok: $SHARDOK_TAG"
$CRANE push "$SHARDOK_IMAGE" "$SHARDOK_TAG"
$CRANE copy "$SHARDOK_TAG" "registry.digitalocean.com/eagle0/shardok-server:latest"
echo "shardok_image_tag=$SHARDOK_TAG" >> $GITHUB_OUTPUT
# Push Admin image
ADMIN_IMAGE="${{ steps.build-all.outputs.admin_path }}"
ADMIN_TAG="registry.digitalocean.com/eagle0/admin-server:${GIT_SHA}"
echo "Pushing Admin: $ADMIN_TAG"
$CRANE push "$ADMIN_IMAGE" "$ADMIN_TAG"
$CRANE copy "$ADMIN_TAG" "registry.digitalocean.com/eagle0/admin-server:latest"
echo "admin_image_tag=$ADMIN_TAG" >> $GITHUB_OUTPUT
# Push JFR Sidecar image
JFR_IMAGE="${{ steps.build-all.outputs.jfr_path }}"
JFR_TAG="registry.digitalocean.com/eagle0/jfr-sidecar:${GIT_SHA}"
echo "Pushing JFR Sidecar: $JFR_TAG"
$CRANE push "$JFR_IMAGE" "$JFR_TAG"
$CRANE copy "$JFR_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest"
echo "jfr_sidecar_image_tag=$JFR_TAG" >> $GITHUB_OUTPUT
echo "=== All images pushed successfully ==="
deploy:
runs-on: self-hosted
needs: [build-all]
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
environment: production
env:
EAGLE_IMAGE: ${{ needs.build-all.outputs.eagle_image_tag }}
SHARDOK_IMAGE: ${{ needs.build-all.outputs.shardok_image_tag }}
ADMIN_IMAGE: ${{ needs.build-all.outputs.admin_image_tag }}
JFR_SIDECAR_IMAGE: ${{ needs.build-all.outputs.jfr_sidecar_image_tag }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GPT_MODEL_NAME: ${{ secrets.GPT_MODEL_NAME }}
EAGLE_ENABLE_S3: ${{ secrets.EAGLE_ENABLE_S3 }}
DO_SPACES_ACCESS_KEY: ${{ secrets.DO_SPACES_ACCESS_KEY }}
DO_SPACES_SECRET_KEY: ${{ secrets.DO_SPACES_SECRET_KEY }}
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }}
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
SHARDOK_ADDRESS: ${{ secrets.SHARDOK_ADDRESS }}
SHARDOK_AUTH_TOKEN: ${{ secrets.SHARDOK_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
FASTMAIL_API_TOKEN: ${{ secrets.FASTMAIL_API_TOKEN }}
FASTMAIL_FROM_EMAIL: ${{ secrets.FASTMAIL_FROM_EMAIL }}
FASTMAIL_FROM_NAME: ${{ secrets.FASTMAIL_FROM_NAME }}
DO_DROPLET_IP: ${{ secrets.DO_DROPLET_IP }}
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DO_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "$DO_DROPLET_IP" >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Build warmup tool
run: |
bazel build //src/main/go/net/eagle0/warmup:warmup_linux_amd64
mkdir -p scripts/bin
cp bazel-bin/src/main/go/net/eagle0/warmup/warmup_linux_amd64_/warmup_linux_amd64 scripts/bin/warmup
- name: Copy config files to droplet
run: |
# Create directory structure on remote
ssh -i ~/.ssh/deploy_key deploy@"$DO_DROPLET_IP" bash -s << 'SETUP_DIRS'
set -e
mkdir -p /opt/eagle0/scripts/bin /opt/eagle0/nginx /opt/eagle0/deploy
rm -f /opt/eagle0/scripts/bin/warmup
SETUP_DIRS
# Copy files
scp -i ~/.ssh/deploy_key docker-compose.prod.yml deploy@"$DO_DROPLET_IP":/opt/eagle0/
scp -i ~/.ssh/deploy_key nginx/nginx.conf deploy@"$DO_DROPLET_IP":/opt/eagle0/nginx/
scp -i ~/.ssh/deploy_key scripts/deploy-blue-green.sh scripts/warmup-eagle.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/
scp -i ~/.ssh/deploy_key scripts/bin/warmup deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/bin/
scp -i ~/.ssh/deploy_key deploy/env.template deploy/update-env.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/deploy/
- name: Deploy to production droplet
run: |
ssh -i ~/.ssh/deploy_key deploy@"$DO_DROPLET_IP" bash -s << DEPLOY_SCRIPT
set -ex
cd /opt/eagle0
# Environment variables passed via heredoc
EAGLE_IMAGE="${EAGLE_IMAGE}"
SHARDOK_IMAGE="${SHARDOK_IMAGE}"
ADMIN_IMAGE="${ADMIN_IMAGE}"
JFR_SIDECAR_IMAGE="${JFR_SIDECAR_IMAGE}"
OPENAI_API_KEY="${OPENAI_API_KEY}"
GPT_MODEL_NAME="${GPT_MODEL_NAME}"
EAGLE_ENABLE_S3="${EAGLE_ENABLE_S3}"
DO_SPACES_ACCESS_KEY="${DO_SPACES_ACCESS_KEY}"
DO_SPACES_SECRET_KEY="${DO_SPACES_SECRET_KEY}"
JWT_PRIVATE_KEY="${JWT_PRIVATE_KEY}"
DISCORD_CLIENT_ID="${DISCORD_CLIENT_ID}"
DISCORD_CLIENT_SECRET="${DISCORD_CLIENT_SECRET}"
GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID}"
GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET}"
SHARDOK_ADDRESS="${SHARDOK_ADDRESS}"
SHARDOK_AUTH_TOKEN="${SHARDOK_AUTH_TOKEN}"
SENTRY_DSN="${SENTRY_DSN}"
FASTMAIL_API_TOKEN="${FASTMAIL_API_TOKEN}"
FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
# Check Docker has IPv6 support
if ! cat /etc/docker/daemon.json 2>/dev/null | grep -q '"ip6tables"'; then
echo "WARNING: Docker IPv6 not configured. Eagle may not reach Hetzner Shardok."
fi
# Update env vars
chmod +x deploy/update-env.sh
cd deploy && ./update-env.sh \
"EAGLE_IMAGE=\${EAGLE_IMAGE}" \
"SHARDOK_IMAGE=\${SHARDOK_IMAGE}" \
"ADMIN_IMAGE=\${ADMIN_IMAGE}" \
"JFR_SIDECAR_IMAGE=\${JFR_SIDECAR_IMAGE}" \
"OPENAI_API_KEY=\${OPENAI_API_KEY}" \
"GPT_MODEL_NAME=\${GPT_MODEL_NAME:-gpt-4o}" \
"EAGLE_ENABLE_S3=\${EAGLE_ENABLE_S3:-false}" \
"DO_SPACES_ACCESS_KEY=\${DO_SPACES_ACCESS_KEY}" \
"DO_SPACES_SECRET_KEY=\${DO_SPACES_SECRET_KEY}" \
"JWT_PRIVATE_KEY=\${JWT_PRIVATE_KEY}" \
"DISCORD_CLIENT_ID=\${DISCORD_CLIENT_ID}" \
"DISCORD_CLIENT_SECRET=\${DISCORD_CLIENT_SECRET}" \
"GOOGLE_CLIENT_ID=\${GOOGLE_CLIENT_ID}" \
"GOOGLE_CLIENT_SECRET=\${GOOGLE_CLIENT_SECRET}" \
"SHARDOK_ADDRESS=\${SHARDOK_ADDRESS:-shardok:40042}" \
"SHARDOK_AUTH_TOKEN=\${SHARDOK_AUTH_TOKEN}" \
"SENTRY_DSN=\${SENTRY_DSN}" \
"FASTMAIL_API_TOKEN=\${FASTMAIL_API_TOKEN}" \
"FASTMAIL_FROM_EMAIL=\${FASTMAIL_FROM_EMAIL}" \
"FASTMAIL_FROM_NAME=\${FASTMAIL_FROM_NAME}"
cd ..
# Login to registry
echo "\$DO_REGISTRY_TOKEN" | docker login registry.digitalocean.com -u "\$DO_REGISTRY_TOKEN" --password-stdin
echo "Using images: \$EAGLE_IMAGE, \$SHARDOK_IMAGE, \$ADMIN_IMAGE, \$JFR_SIDECAR_IMAGE"
# Install crane for pulling OCI images
echo "Installing crane..."
rm -f crane
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
chmod +x crane
# Pull and load all images
echo "Pulling Eagle image..."
./crane pull "\${EAGLE_IMAGE}" eagle.tar && docker load -i eagle.tar && rm eagle.tar
echo "Pulling Shardok image..."
./crane pull "\${SHARDOK_IMAGE}" shardok.tar && docker load -i shardok.tar && rm shardok.tar
echo "Pulling Admin image..."
./crane pull "\${ADMIN_IMAGE}" admin.tar && docker load -i admin.tar && rm admin.tar
echo "Pulling JFR Sidecar image..."
./crane pull "\${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar && docker load -i jfr-sidecar.tar && rm jfr-sidecar.tar
# Pull other compose images
docker pull nginx:alpine || true
docker pull certbot/certbot || true
echo "All images pulled successfully"
# Recreate shardok (stateless, safe to restart anytime)
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate shardok
# Deploy Eagle with blue-green (handles eagle, nginx, admin, jfr-sidecar)
chmod +x /opt/eagle0/scripts/*.sh
[ -f "/opt/eagle0/scripts/bin/warmup" ] && chmod +x /opt/eagle0/scripts/bin/warmup
GIT_SHA=\$(echo "\${EAGLE_IMAGE}" | sed 's/.*://')
/opt/eagle0/scripts/deploy-blue-green.sh "\${GIT_SHA}"
# Ensure auth is running
docker compose -f docker-compose.prod.yml up -d auth
# Verify
sleep 10
docker compose -f docker-compose.prod.yml ps
docker compose -f docker-compose.prod.yml images
# Cleanup
docker container prune -f
docker image prune -f
DEPLOY_SCRIPT
+5 -34
View File
@@ -10,7 +10,6 @@ on:
paths:
- ".github/workflows/installer_build.yml"
- "src/main/csharp/net/eagle0/clients/win/installer/**"
workflow_dispatch:
permissions:
contents: read
@@ -30,19 +29,6 @@ jobs:
with:
dotnet-version: '8.0.x'
- name: Inject manifest public key
env:
MANIFEST_PUBLIC_KEY: ${{ secrets.MANIFEST_PUBLIC_KEY }}
run: |
if [ -n "$MANIFEST_PUBLIC_KEY" ]; then
CONFIG_FILE="src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/configuration.txt"
echo "manifest_public_key = $MANIFEST_PUBLIC_KEY" >> "$CONFIG_FILE"
echo "Injected manifest public key into configuration.txt"
cat "$CONFIG_FILE"
else
echo "MANIFEST_PUBLIC_KEY not set, skipping injection"
fi
- name: Restore dependencies
run: dotnet restore src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/EagleInstaller.csproj
@@ -82,30 +68,15 @@ jobs:
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
run: |
# Create installer manifest content
# Create installer manifest content
INSTALLER_SHA=$(sha256sum ./installer-output/EagleInstaller.exe | cut -d' ' -f1)
echo "installer_version=$INSTALLER_SHA" > /tmp/installer_manifest.txt
echo "installer_url=installer/EagleInstaller.exe" >> /tmp/installer_manifest.txt
echo "=== Installer manifest content ==="
cat /tmp/installer_manifest.txt
echo "=================================="
# Write signing key to temp file (if available)
SIGNING_ARGS=""
if [ -n "$MANIFEST_SIGNING_KEY" ]; then
echo "$MANIFEST_SIGNING_KEY" > /tmp/manifest_signing_key
chmod 600 /tmp/manifest_signing_key
SIGNING_ARGS="/tmp/manifest_signing_key"
echo "Manifest signing key available"
else
echo "Warning: MANIFEST_SIGNING_KEY not set, manifest will be unsigned"
fi
# Update the unified manifest (with optional signing)
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- installer /tmp/installer_manifest.txt $SIGNING_ARGS
# Cleanup
rm -f /tmp/manifest_signing_key
# Update the unified manifest
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- installer /tmp/installer_manifest.txt
-154
View File
@@ -1,154 +0,0 @@
name: Mac Build
on:
push:
branches: [ "main" ]
paths:
- ".github/workflows/mac_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/**"
- "src/main/proto/**"
- "src/main/go/net/eagle0/build/mac_build_handler/**"
- "scripts/build_protos.sh"
- "scripts/build_mac_plugin.sh"
- "scripts/inject_sparkle.sh"
- "scripts/codesign_mac_app.sh"
- "scripts/notarize_mac_app.sh"
- "ci/github_actions/build_mac.sh"
- "ci/github_actions/build_unity_mac.sh"
- "ci/mac/**"
pull_request:
paths:
- ".github/workflows/mac_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/**"
- "src/main/go/net/eagle0/build/mac_build_handler/**"
- "scripts/build_mac_plugin.sh"
- "scripts/inject_sparkle.sh"
- "scripts/codesign_mac_app.sh"
- "scripts/notarize_mac_app.sh"
- "ci/github_actions/build_mac.sh"
- "ci/github_actions/build_unity_mac.sh"
- "ci/mac/**"
workflow_dispatch:
inputs:
skip_signing:
description: 'Skip code signing, notarization, and deploy (build only)'
required: false
default: 'false'
type: boolean
permissions:
contents: read
jobs:
mac-unity:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
with:
lfs: true
clean: false
fetch-depth: 0 # For version numbering from git history
- name: Pull LFS files
run: git lfs pull
- name: Restore Library/
run: ./ci/github_actions/restore_library.sh
- name: Build Mac Unity
run: ./ci/github_actions/build_unity_mac.sh "/tmp/eagle0/eagle0MAC"
- name: Persist Library/
run: ./ci/github_actions/persist_library.sh
- name: Inject Sparkle Framework
if: success()
env:
SPARKLE_EDDSA_PUBLIC_KEY: ${{ secrets.SPARKLE_EDDSA_PUBLIC_KEY }}
run: |
chmod +x ./scripts/inject_sparkle.sh
./scripts/inject_sparkle.sh "/tmp/eagle0/eagle0MAC/eagle0.app"
- name: Import Code Signing Certificate
if: success() && ((github.event_name == 'workflow_dispatch' && github.event.inputs.skip_signing != 'true') || (github.event_name == 'push' && github.ref == 'refs/heads/main'))
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
run: |
# Generate random keychain password (only used within this workflow run)
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> $GITHUB_ENV
# Decode certificate
echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12
# Delete any existing keychain from previous runs
security delete-keychain build.keychain 2>/dev/null || true
# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
# Import certificate
security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign
# Allow codesign to access keychain
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
# Clean up
rm certificate.p12
- name: Code Sign App
if: success() && ((github.event_name == 'workflow_dispatch' && github.event.inputs.skip_signing != 'true') || (github.event_name == 'push' && github.ref == 'refs/heads/main'))
env:
SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
run: |
chmod +x ./scripts/codesign_mac_app.sh
./scripts/codesign_mac_app.sh "/tmp/eagle0/eagle0MAC/eagle0.app" "ci/mac/eagle0.entitlements"
- name: Notarize App
if: success() && ((github.event_name == 'workflow_dispatch' && github.event.inputs.skip_signing != 'true') || (github.event_name == 'push' && github.ref == 'refs/heads/main'))
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
chmod +x ./scripts/notarize_mac_app.sh
./scripts/notarize_mac_app.sh "/tmp/eagle0/eagle0MAC/eagle0.app"
- name: Deploy Mac Build
if: success() && ((github.event_name == 'workflow_dispatch' && github.event.inputs.skip_signing != 'true') || (github.event_name == 'push' && github.ref == 'refs/heads/main'))
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
SPARKLE_EDDSA_PRIVATE_KEY: ${{ secrets.SPARKLE_EDDSA_PRIVATE_KEY }}
run: |
# Write private key to temp file for signing
SPARKLE_PRIVATE_KEY_PATH="/tmp/sparkle_private_key"
echo "$SPARKLE_EDDSA_PRIVATE_KEY" > "$SPARKLE_PRIVATE_KEY_PATH"
chmod 600 "$SPARKLE_PRIVATE_KEY_PATH"
VERSION=$(git describe --tags --always)
BUILD_NUMBER=$(git rev-list --count HEAD)
bazel run //src/main/go/net/eagle0/build/mac_build_handler:mac_build_handler -- \
"/tmp/eagle0/eagle0MAC/eagle0.app" \
"$VERSION" \
"$BUILD_NUMBER" \
"$SPARKLE_PRIVATE_KEY_PATH"
rm "$SPARKLE_PRIVATE_KEY_PATH"
- name: Cleanup Keychain
if: always()
run: |
security delete-keychain build.keychain 2>/dev/null || true
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: editor_mac.log
path: /tmp/eagle0/editor_mac.log
+29
View File
@@ -0,0 +1,29 @@
name: Mac History Editor Build
on:
push:
branches: [ "main" ]
paths:
- ".github/workflows/mac_history_build.yml"
- "src/main/swift/net/eagle0/EagleGameHistoryViewer/**"
- "src/main/protobuf/net/eagle0/eagle/**"
pull_request:
paths:
- ".github/workflows/mac_history_build.yml"
- "src/main/swift/net/eagle0/EagleGameHistoryViewer/**"
- "src/main/protobuf/net/eagle0/eagle/**"
permissions:
contents: read
jobs:
mac-history-build:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
with:
lfs: false
clean: false
- name: Build the mac history
run: ./ci/github_actions/build_mac_history.sh
-92
View File
@@ -1,92 +0,0 @@
name: Cleanup Old Container Images
on:
schedule:
# Run daily at 3am UTC
- cron: '0 3 * * *'
workflow_dispatch:
inputs:
dry_run:
description: 'Dry run (show what would be deleted without deleting)'
required: true
default: 'true'
type: boolean
permissions:
contents: read
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Install doctl
uses: digitalocean/action-doctl@v2
with:
token: ${{ secrets.DO_REGISTRY_TOKEN }}
- name: Cleanup old images
env:
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true' }}
run: |
set -e
RETENTION_DAYS=5
CUTOFF_DATE=$(date -d "-${RETENTION_DAYS} days" +%s)
REGISTRY="eagle0"
echo "Cleaning up images older than ${RETENTION_DAYS} days"
echo "Cutoff date: $(date -d "@${CUTOFF_DATE}" -Iseconds)"
echo "Dry run: ${DRY_RUN}"
echo ""
# List of repositories to clean
# Use tail to skip header row in case --no-header doesn't work
REPOS=$(doctl registry repository list-v2 --format Name --no-header | grep -v '^Name$' | grep -v '^$')
for REPO in $REPOS; do
echo "=== Processing repository: ${REPO} ==="
# Get all manifests with their tags and dates
# Filter out header row and empty lines
MANIFESTS=$(doctl registry repository list-manifests "${REPO}" --format Digest,UpdatedAt,Tags --no-header 2>/dev/null | grep -v '^Digest' | grep -v '^$' || echo "")
if [ -z "$MANIFESTS" ]; then
echo " No manifests found"
continue
fi
echo "$MANIFESTS" | while read -r DIGEST UPDATED_AT TAGS; do
# Skip if no digest or if it doesn't look like a valid digest (sha256:...)
if [ -z "$DIGEST" ] || ! echo "$DIGEST" | grep -q '^sha256:'; then
continue
fi
# Parse the date
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."
-221
View File
@@ -1,221 +0,0 @@
name: Shardok ARM64 Build and Push
on:
push:
branches: [ "main" ]
paths:
- 'src/main/cpp/**'
- 'src/main/protobuf/net/eagle0/shardok/**'
- 'src/main/protobuf/net/eagle0/common/**'
- 'src/main/resources/net/eagle0/shardok/**'
- 'ci/BUILD.bazel'
- 'MODULE.bazel'
- '.github/workflows/shardok_arm64_build.yml'
workflow_dispatch:
inputs:
push_images:
description: 'Push images to container registry'
required: true
default: 'true'
type: boolean
permissions:
contents: read
jobs:
build-shardok-arm64:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Shardok ARM64 binary (cross-compile for Linux ARM64)
run: |
set -ex
echo "=== Building shardok-server binary for linux-aarch64 ==="
bazel build \
--platforms=//:linux_arm64 \
--extra_toolchains=@llvm_toolchain_linux_arm64//:all \
//src/main/cpp/net/eagle0/shardok:shardok-server
LINUX_BIN="bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server"
echo "=== Checking binary at: $LINUX_BIN ==="
if [ ! -f "$LINUX_BIN" ]; then
echo "ERROR: Binary not found at $LINUX_BIN"
exit 1
fi
# Verify it's ELF (Linux) not Mach-O (macOS)
echo "=== Verifying binary format ==="
MAGIC=$(head -c 4 "$LINUX_BIN" | xxd -p)
echo "Binary magic bytes: $MAGIC"
if [ "$MAGIC" = "7f454c46" ]; then
echo "SUCCESS: Binary is ELF format (Linux)"
# Check if it's ARM64 (e_machine = 0xB7 = 183 for aarch64)
E_MACHINE=$(od -An -j18 -N2 -tx2 "$LINUX_BIN" | tr -d ' ')
echo "ELF e_machine: $E_MACHINE"
if [ "$E_MACHINE" = "b700" ]; then
echo "SUCCESS: Binary is ARM64 (aarch64)"
else
echo "WARNING: Binary e_machine is $E_MACHINE (expected b700 for aarch64)"
fi
elif [ "$MAGIC" = "cfaeedfe" ] || [ "$MAGIC" = "cffaedfe" ]; then
echo "ERROR: Binary is Mach-O format (macOS) - cross-compilation failed!"
exit 1
else
echo "WARNING: Unknown binary format: $MAGIC"
file "$LINUX_BIN" || true
fi
- name: Build Shardok ARM64 Docker image
id: build-shardok
run: |
set -ex
bazel build \
--platforms=//:linux_arm64 \
--extra_toolchains=@llvm_toolchain_linux_arm64//:all \
//ci:shardok_server_image_arm64
IMAGE_PATH=$(readlink -f bazel-bin/ci/shardok_server_image_arm64)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
# Verify the binary inside the tar layer is ARM64 ELF
echo "=== Verifying binary in image tar ==="
BINARY_TAR="bazel-bin/ci/shardok_binary_layer_arm64.tar"
if [ -f "$BINARY_TAR" ]; then
echo "Checking binary in $BINARY_TAR"
MAGIC=$(tar -xOf "$BINARY_TAR" app/shardok-server 2>/dev/null | head -c 4 | xxd -p)
echo "Binary magic in tar: $MAGIC"
if [ "$MAGIC" = "7f454c46" ]; then
echo "SUCCESS: Binary in tar is ELF format (Linux)"
else
echo "ERROR: Binary in tar is NOT ELF format!"
exit 1
fi
else
echo "WARNING: Could not find $BINARY_TAR"
fi
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
AUTH=$(echo -n "${DO_REGISTRY_TOKEN}:${DO_REGISTRY_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
- name: Push Shardok ARM64 image to DigitalOcean
id: push-shardok
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
run: |
set -ex
CROSS_IMAGE="${{ steps.build-shardok.outputs.image_path }}"
echo "Using cross-compiled image: $CROSS_IMAGE"
if [ -z "$CROSS_IMAGE" ] || [ ! -d "$CROSS_IMAGE" ]; then
echo "ERROR: Cross-compiled image not found at: $CROSS_IMAGE"
exit 1
fi
# Build a push target to get crane in runfiles
bazel build //ci:eagle_server_push
# Find the Darwin crane binary
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
if [ -z "$CRANE" ]; then
CRANE=$(find "$RUNFILES" -name crane 2>/dev/null | head -1)
fi
if [ -z "$CRANE" ] || [ ! -e "$CRANE" ]; then
echo "ERROR: crane not found"
exit 1
fi
echo "Using crane: $CRANE"
# Push with arm64-prefixed SHA tag (same repo as x86, different tag)
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/shardok-server:arm64-${GIT_SHA}"
echo "Pushing shardok ARM64 image: $IMAGE_TAG"
$CRANE push "$CROSS_IMAGE" "$IMAGE_TAG"
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :arm64-latest tag for convenience
echo "Copying to :arm64-latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/shardok-server:arm64-latest"
echo "=== Push complete ==="
echo "Image: $IMAGE_TAG"
echo "Also tagged as: registry.digitalocean.com/eagle0/shardok-server:arm64-latest"
deploy-hetzner:
runs-on: self-hosted
needs: [build-shardok-arm64]
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
environment: production
env:
SHARDOK_IMAGE: ${{ needs.build-shardok-arm64.outputs.image_tag }}
steps:
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.HETZNER_SSH_KEY }}" > ~/.ssh/hetzner_deploy
chmod 600 ~/.ssh/hetzner_deploy
# Add host key to known_hosts to avoid prompt
ssh-keyscan -H ${{ secrets.HETZNER_IP }} >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Deploy to Hetzner
run: |
ssh -i ~/.ssh/hetzner_deploy -o StrictHostKeyChecking=accept-new deploy@${{ secrets.HETZNER_IP }} << 'ENDSSH'
set -ex
cd /opt/eagle0
# Login to registry
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
echo "Deploying Shardok ARM64: ${{ needs.build-shardok-arm64.outputs.image_tag }}"
# Pull the new image
docker pull "${{ needs.build-shardok-arm64.outputs.image_tag }}"
# Stop and remove any container using port 40042 or named shardok*
docker ps -q --filter "publish=40042" | xargs -r docker stop
docker ps -aq --filter "name=shardok" | xargs -r docker rm -f
docker ps -aq --filter "publish=40042" | xargs -r docker rm -f
# Run new container
docker run -d \
--name shardok-ai \
--restart unless-stopped \
-p 40042:40042 \
-v /opt/eagle0/data:/data \
-v /etc/shardok:/etc/shardok:ro \
-v /etc/letsencrypt:/etc/letsencrypt:ro \
-v /usr/local/share/eagle0:/usr/local/share/eagle0:ro \
-e SHARDOK_RESOURCES_PATH=/app/resources \
-e SHARDOK_MAPS_PATH=/app/resources/maps \
"${{ needs.build-shardok-arm64.outputs.image_tag }}"
# Wait and verify
sleep 5
docker ps | grep shardok-ai
# Cleanup old images
docker image prune -f
echo "=== Hetzner deployment complete ==="
ENDSSH
- name: Cleanup SSH key
if: always()
run: rm -f ~/.ssh/hetzner_deploy
+3 -21
View File
@@ -16,7 +16,6 @@ on:
- "MODULE.bazel"
- "WORKSPACE"
- "src/main/proto/net/eagle0/eagle/**/BUILD.bazel"
workflow_dispatch:
pull_request:
paths:
- ".github/workflows/unity_build.yml"
@@ -53,35 +52,18 @@ jobs:
- name: Persist Library/
run: ./ci/github_actions/persist_library.sh
- name: Deploy Windows unity
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
if: success() #&& github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: bazel run //src/main/go/net/eagle0/build/unity3d_windows_build_handler:unity3d_windows_build_handler -- "/tmp/eagle0/eagle0WIN" "/tmp/unity_manifest.txt"
- name: Update unified manifest
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
if: success() #&& github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
run: |
# Write signing key to temp file (if available)
SIGNING_ARGS=""
if [ -n "$MANIFEST_SIGNING_KEY" ]; then
echo "$MANIFEST_SIGNING_KEY" > /tmp/manifest_signing_key
chmod 600 /tmp/manifest_signing_key
SIGNING_ARGS="/tmp/manifest_signing_key"
echo "Manifest signing key available"
else
echo "Warning: MANIFEST_SIGNING_KEY not set, manifest will be unsigned"
fi
# Update the unified manifest (with optional signing)
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- unity3d /tmp/unity_manifest.txt $SIGNING_ARGS
# Cleanup
rm -f /tmp/manifest_signing_key
run: bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- unity3d /tmp/unity_manifest.txt
- name: Archive build log
if: success() || failure()
uses: actions/upload-artifact@v4
-2
View File
@@ -37,5 +37,3 @@ scripts/refresh_name_layers/refresh_name_layers.zip
.metals
api_keys.txt
src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/Packages/com.unity.dedicated-server/
node_modules/
+8 -2
View File
@@ -32,6 +32,12 @@ repos:
- id: gazelle
name: gazelle
language: system
entry: ./scripts/pre-commit-gazelle.sh
entry: bazel run //:gazelle
files: '(\.go|\.proto|BUILD\.bazel|BUILD|WORKSPACE|WORKSPACE\.bazel|\.bzl)$'
pass_filenames: false
- repo: local
hooks:
- id: update-action-result-types
name: update-action-result-types
language: system
entry: ./scripts/updateActionResultTypes.sh
files: 'src/main/protobuf/net/eagle0/eagle/common/action_result_type.proto'
-18
View File
@@ -3,24 +3,6 @@ load("@io_bazel_rules_go//go:def.bzl", "nogo")
package(default_visibility = ["//visibility:public"])
# Platform for cross-compiling to Linux x86_64
platform(
name = "linux_x86_64",
constraint_values = [
"@platforms//os:linux",
"@platforms//cpu:x86_64",
],
)
# Platform for cross-compiling to Linux ARM64
platform(
name = "linux_arm64",
constraint_values = [
"@platforms//os:linux",
"@platforms//cpu:aarch64",
],
)
gazelle(name = "gazelle")
# gazelle:proto file
-79
View File
@@ -1,23 +1,5 @@
# CLAUDE.md
## CRITICAL GIT RULES (NEVER VIOLATE)
**NEVER push directly to main/master.** No exceptions. Not for "small changes." Not for docs. Not ever.
**NEVER merge PRs.** You create PRs. The user merges them. No exceptions.
**ALWAYS use this workflow:**
1. Create a feature branch from origin/main
2. Commit to that branch
3. Create a PR with `gh pr create`
4. Wait for user to merge (DO NOT run `gh pr merge`)
If you catch yourself about to run `git push origin main` or `git push origin <branch>:main`, STOP. You are about to violate a critical rule. Create a PR instead.
If you catch yourself about to run `gh pr merge`, STOP. Only the user merges PRs.
---
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
@@ -103,16 +85,6 @@ bazel run gazelle # Update Go build files
./scripts/updateActionResultTypes.sh # Update protocol buffer mappings
```
### Pre-Commit Checklist
**MANDATORY: Before running `git commit`, verify:**
1. **If you modified any BUILD.bazel file:** Run `bazel run gazelle` and stage any changes it makes
2. **If you modified C++ or C# files:** Run `clang-format -i` on the modified files
3. **If you modified Scala files:** scalafmt will run automatically via pre-commit hook
The pre-commit hook runs gazelle but only checks if it succeeds - it does NOT verify the BUILD files are in canonical format. The `gazelle_test` will fail if deps are not alphabetically sorted. **Always run gazelle manually after BUILD file changes.**
### Code Formatting
```bash
@@ -234,31 +206,6 @@ to be used for different players or game situations within the same server proce
- Map validation tests ensure game content integrity
- Use `GameSettings_test_utils.cpp` and `ShardokEngineBasedTestData.cpp` for C++ test helpers
### Scala Testing Patterns
**Use `inside()` instead of `asInstanceOf` for type matching in tests:**
Never use `asInstanceOf` in tests. Instead, use ScalaTest's `inside()` pattern for safe type matching:
```scala
// BAD - don't do this
val changedHero = result.changedHeroes.head.asInstanceOf[ChangedHeroC]
changedHero.heroId shouldBe 19
// GOOD - use inside() pattern
import org.scalatest.Inside.inside
inside(result.changedHeroes.head) { case changedHero: ChangedHeroC =>
changedHero.heroId shouldBe 19
changedHero.vigorChange shouldBe StatDelta(17.2)
}
```
The `inside()` pattern:
- Provides better error messages when the type doesn't match
- Is idiomatic ScalaTest
- Works with pattern matching for more complex assertions
## Performance Testing
When making performance-related changes to the AI or engine:
@@ -297,32 +244,6 @@ done
- **Always test performance changes** - what seems like an optimization may sometimes have unexpected overhead or
behavior changes.
## Troubleshooting Scala Build Errors
### MissingType Errors
When you see errors like:
```
dotty.tools.dotc.core.MissingType: Cannot resolve reference to type net.eagle0.eagle.internal.game_state.type.GameState
```
**This is NOT a Scala compiler crash.** This is a missing dependency in BUILD.bazel.
**How to fix:**
1. Identify the missing type from the error message (e.g., `game_state.GameState`)
2. Find the Bazel target that provides this type (e.g., `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`)
3. Add it to the `deps` of the failing target
4. If the type appears in a public method signature, also add it to `exports` so downstream targets can see it
**Common pattern:** When adding a method to a class that takes or returns a proto type, the proto dependency often needs to be added to both `deps` AND `exports`.
### Bazel Clean
**NEVER run `bazel clean` without asking first.** It rarely fixes actual issues and wastes significant rebuild time. The issues that seem like they need `bazel clean` are usually:
- Missing imports in Scala code
- Missing dependencies in BUILD.bazel
- Missing exports for types used in public signatures
## Game Content
**Maps:** `.e0mj` files in `/src/main/resources/net/eagle0/shardok/maps/`
-213
View File
@@ -1,213 +0,0 @@
# Deproto Migration Plan
This document tracks the migration from protobuf types to native Scala models inside the Eagle game engine.
## Architectural Decisions
1. **Keep proto for persistence**: Yes - protobuf is used for persisting game state
2. **Keep proto for Shardok communication**: Yes - protobuf is used for Eagle-Shardok gRPC communication
3. **Use Scala views inside the library**: Yes - use native Scala types like `ProvinceView`, `FactionView`, `HeroView`, etc. within the library code
## Recent Completed Work
### GameState Round-Trip Elimination (PRs #4913, #4914, #4915)
Eliminated wasteful Scala→proto→Scala conversions in the hot path:
1. **LLM Pipeline** (#4913): `LlmRequestWithGameState` now uses Scala `GameState` instead of proto. All ~38 prompt generators updated to use Scala model types (`FactionT`, `HeroT`, `ProvinceT`).
2. **ActionWithResultingState Caching** (#4914): Added `precomputedScalaState: Option[GameState]` to cache Scala state when available, avoiding `fromProto()` conversion in `stateAfter()`.
3. **PostResults Simplification** (#4915): Changed `PostResults.gameState` from proto to `Option[GameState]` (Scala), eliminating `toProto()` calls when creating PostResults.
## Migration Pattern
The codebase follows a **Legacy* pattern** for separating proto-dependent and protoless code:
- **Protoless utilities**: `FactionUtils`, `HeroUtils`, `ProvinceUtils`, `ProvinceDistances`, etc.
- **Proto-dependent utilities**: `LegacyFactionUtils`, `LegacyHeroUtils`, `LegacyProvinceUtils`, `LegacyProvinceDistances`, etc.
When migrating a file:
1. Create a `Legacy*` version containing the proto-dependent methods
2. Keep the original file name for protoless methods
3. Update callers to use the appropriate version based on their context
## Migration Status
### Fully Protoless (no proto imports)
**Utilities:**
- [x] `FactionUtils` - has protoless `ownedNeighbors` method
- [x] `ProvinceDistances` - split into protoless + `LegacyProvinceDistances`
- [x] `SwornBrotherChooser` - fully protoless (removed `bestChoiceProto`)
**Command Selectors (all use native GameState):**
- [x] `AllianceOfferCommandSelector`
- [x] `AlmsCommandSelector`
- [x] `AttackCommandChooser`
- [x] `ExpandCommandSelector`
- [x] `HeroGiftCommandSelector`
- [x] `ImproveCommandSelector`
- [x] `MarchTowardProvinceCommandChooser` - in AI folder, uses native GameState (callers convert)
- [x] `OrganizeCommandSelector`
- [x] `RansomOfferHelpers`
- [x] `SeekMoreLeadersCommandChooser` - in AI folder, uses native GameState
- [x] `TruceOfferCommandSelector`
- [x] `TrustForDiplomacy`
**Quest Command Selectors (all protoless):**
- [x] `AllianceQuestCommandChooser`
- [x] `AlmsAcrossRealmQuestCommandChooser`
- [x] `AlmsToProvinceQuestCommandChooser`
- [x] `DismissSpecificVassalCommandChooser`
- [x] `GiveToHeroesAcrossRealmQuestCommandChooser`
- [x] `GiveToHeroesInProvinceQuestCommandChooser`
- [x] `ImproveQuestCommandChooser`
- [x] `QuestCommandChooser`
- [x] `TruceCountQuestCommandChooser`
- [x] `TruceWithFactionQuestCommandChooser`
### Fully Protoless
- [x] `AIClientUtils` - has protoless overloads (`takenHeroIdsForMarchTowardFocus`, `mostPowerfulHeroes`)
- [x] `AttackCommandChooser` - uses Scala `GameState` and `BattalionViewC` (Scala view type)
- [x] `BattalionPower` - has `estimatedPower(BattalionViewC)` for recon data with optional stats
- [x] `CommandChoiceHelpers` - fully protoless, uses Scala `GameState` throughout
- [x] `ProvinceGoldSurplusCalculator` - fully protoless (callers use converters)
- [x] `HeroSelector` - fully protoless (removed dead `minimallyFatiguedHeroesProto`)
### AI Layer ✅ COMPLETE
All AI and command chooser code is now fully protoless:
- [x] `AttackDecisionCommandChooser` - uses Scala GameState
- [x] `CommandChooser` - trait uses Scala GameState
- [x] `FulfillQuestsCommandSelector` - uses Scala GameState
- [x] `MidGameAIClient` - uses Scala GameState internally
### Still Using Proto GameState (Boundary Code)
These files use proto GameState because they're at system boundaries:
**View Filters (client projection):**
- `view_filters/GameStateViewFilter` - has Scala overload, uses Scala sub-filters
- `view_filters/ProvinceViewFilter` - has Scala overloads for some methods
- `view_filters/FactionViewFilter` - has Scala overload
- `view_filters/HeroViewFilter` - has Scala overload
- `view_filters/BattalionNameFilter` - has Scala overload
- `view_filters/BattleFilter` - has Scala overload
**Legacy Utilities (to be deprecated):**
- `LegacyProvinceDistances`, `LegacyFactionUtils`, `LegacyHeroUtils`, etc.
- Used by code that still needs proto GameState
**Persistence/Action System:**
- `ActionResultTApplier`, `ActionResultProtoApplier` - apply results to proto state
- `ActionWithResultingState` - caches both proto and Scala state
**Shardok Interface (gRPC boundary):**
- `ShardokInterfaceGrpcClient`, `ShardokInterfaceProxy` - must use proto for C++ communication
## Next Steps
### Phase 1-3: AI Layer ✅ COMPLETE
The entire AI decision-making layer is now protoless.
### Phase 4: View Filters ✅ COMPLETE
The view_filters package migration is complete:
**Completed:**
- [x] `GameStateViewFilter` - added Scala GameState overload (uses Scala sub-filters)
- [x] `ProvinceViewFilter` - already has Scala overloads for server-side views
- [x] `HumanPlayerClientConnectionState` - updated to pass Scala GameState directly
- [x] `HeroViewFilter` - added Scala overload
- [x] `FactionViewFilter` - added Scala overload
- [x] `Visibility` - added Scala overloads
**Still Using Proto:**
- [x] `BattalionNameFilter` - has Scala overload
- [x] `BattleFilter` - has Scala overload
- [ ] `ActionResultFilter` - uses proto internally (boundary code)
**Strategy:**
1. Add Scala GameState overloads to view filter methods
2. Update callers to pass Scala GameState where available
3. Eventually deprecate proto versions
### Phase 5: Legacy Utility Cleanup (IN PROGRESS)
Remove Legacy* utilities by migrating remaining callers:
1. Identify callers of each Legacy* util
2. Update callers to use protoless versions
3. Delete Legacy* files when no longer needed
**Deleted (no production callers):**
- [x] `LegacyProvinceDistances` - deleted (no callers)
- [x] `LegacyBattalionSuitability` - deleted (no callers)
- [x] `LegacyFoodConsumptionUtils` - deleted (no callers)
- [x] `LegacyHandleRiotUtils` - deleted (no callers)
**Refactored to Thin Wrappers (delegating to protoless versions):**
- [x] `LegacyRansomValidity` - already a thin wrapper delegating to `RansomValidity`
- [x] `LegacyRecruitmentOdds` - refactored to delegate to `RecruitmentOdds`
**Parallel Implementations (proto mirrors protoless):**
- [x] `FactionUtils` / `LegacyFactionUtils` - both have matching APIs; LegacyFactionUtils used by boundary code (24+ callers)
- [x] `HeroUtils` / `LegacyHeroUtils` - both have matching APIs; LegacyHeroUtils used by boundary code (10 callers)
- [x] `ProvinceUtils` / `LegacyProvinceUtils` - both have matching APIs; LegacyProvinceUtils used by boundary code (20 callers: availability factories, view filters)
**Parallel Implementations (awaiting migration of callers):**
- [x] `BattalionUtils` / `LegacyBattalionUtils` - both have matching core methods; LegacyBattalionUtils used by boundary code (4 callers)
- [x] `BattalionViewFilter` / `LegacyBattalionViewFilter` - protoless version exists; Legacy used by view filters, action appliers (3 callers)
- [x] `BattalionTypeFinder` / `LegacyBattalionTypeFinder` - protoless version exists; Legacy used by validators (1 caller: RuntimeValidator)
### Recent Caller Migration
**CheckForFulfilledQuestsAction** - migrated to use protoless `BattalionTypeFinder`:
- Changed `battalionTypes` parameter from proto `Vector[BattalionType]` to Scala `Vector[BattalionType]`
- Updated callers (EngineImpl, EndVassalCommandsPhaseAction) to pass Scala types directly
- Eliminated wasteful `BattalionTypeConverter.toProto()` conversions
**ExpandedUnaffiliatedHeroUtils** - added Scala overload:
- New overload takes Scala `GameState` and `UnaffiliatedHeroT` directly
- Added `UnaffiliatedHeroConverter.unaffiliatedHeroTypeToProto()` helper for efficient enum conversion
- Proto overload retained for backward compatibility
**AvailablePleaseRecruitMeCommandFactory** - eliminated wasteful proto conversions:
- Now uses `ExpandedUnaffiliatedHeroUtils.expandedUnaffiliatedHero(ScalaGameState, UnaffiliatedHeroT)` directly
- Removed `GameStateConverter.toProto()` and `UnaffiliatedHeroConverter.toProto()` calls
- Factory is now fully protoless internally (still returns proto types for API boundary)
**ProvinceViewFilter** - added Scala overload with faction filtering:
- New `filteredProvinceView(province: ProvinceT, gs: ScalaGameState, factionId: FactionId)` overload
- Uses protoless `FactionUtils.hasAlliance`, `Visibility.hasFullVisibility`, and `ProvinceUtils.incomingOthers`
- Handles reconned provinces directly from Scala `FactionT.reconnedProvinces` (already Scala type)
- Added helper methods: `fullProvinceInfoScala`, `maybeIncomingAttackersScala`, `unaffiliatedHeroInfoScala`
- Events still converted to proto at the end (ProvinceView.knownEvents uses proto events)
**GameStateViewFilter** - eliminated GameStateConverter.toProto() call:
- Scala overload now fully protoless internally
- Uses the new ProvinceViewFilter Scala overload with faction filtering
- Converts `battalionTypes` and `chronicleEntries` to proto only at output boundary
## Key Files
### Protoless Model Types
- `src/main/scala/net/eagle0/eagle/model/state/game_state/GameState.scala` - native Scala GameState
- `src/main/scala/net/eagle0/eagle/model/state/province/ProvinceView.scala` - province view type
- `src/main/scala/net/eagle0/eagle/model/state/faction/FactionView.scala` - faction view type
- `src/main/scala/net/eagle0/eagle/model/state/hero/HeroView.scala` - hero view type
### Proto Converters
- `src/main/scala/net/eagle0/eagle/model/proto_converters/game_state/` - converts between proto and Scala types
## Notes
- The AI client code (`src/main/scala/net/eagle0/eagle/ai/`) is now fully protoless
- Proto GameState is still needed at boundaries: persistence, gRPC to Shardok
- `PerformUnaffiliatedHeroesAction` and the LLM pipeline use protoless `GameState`
- `GameStateViewFilter` Scala overload is now fully protoless internally (converts to proto only at output)
- `ProvinceViewFilter` has Scala overloads for all three modes: no filtering, faction filtering, and withdrawn-from view
+463
View File
@@ -0,0 +1,463 @@
# MCTS Transposition Table Enhancement: Caching State Transitions
## Executive Summary
**Goal:** Avoid redundant `PostCommand` calls by caching state transitions (stateHash, actionIndex, roll) → nextStateHash.
**Key Findings:**
1. ✅ MCTS already has thread-local cache (`legalActionsCache_`) with 70-90% hit rates
2.**DETERMINISM SOLUTION:** Cache (action, roll) tuples instead of just action
- Initial implementation: always use roll=50
- Future-proof: supports chance nodes with multiple rolls (10, 30, 50, 70, 90)
- Architecturally solves the non-determinism problem
3. ✅ Enhancement is straightforward: add `actionResults` map to existing `LegalActionsCache` struct
4. ✅ Expected benefit: Eliminate PostCommand overhead on cache hits (could save 5-15 microseconds per hit)
**Recommendation:** Implement using (action, roll) tuple keys. Initial implementation uses roll=50 for all transitions, but data structure supports future expansion to chance nodes.
---
## Current Implementation
**IMPORTANT:** MCTS does NOT use the TranspositionTable.hpp/cpp files. Those are for IterativeDeepening AI.
MCTS uses a thread-local cache in `ShardokGameEngine` (src/main/cpp/net/eagle0/shardok/ai/mcts/adapters/ShardokGameEngine.cpp):
```cpp
// Thread-local cache (line 20-21)
thread_local gtl::flat_hash_map<uint64_t, ShardokGameEngine::LegalActionsCache> legalActionsCache_;
struct LegalActionsCache {
std::shared_ptr<ShardokEngine> engine; // Cached engine with populated command cache
std::vector<size_t> filteredIndices; // Filtered action indices
};
```
**Current cache mapping:**
```
stateHash -> LegalActionsCache {
ShardokEngine engine, // Engine with command cache populated
vector<size_t> filteredIndices // Filtered action indices
}
```
**Purpose:** Avoid recalculating GetAvailableCommands and FilterCommands for states we've seen before.
**Performance:** Already achieving ~70-90% hit rates in typical searches (see reportCacheStatistics() output).
## Proposed Enhancement
Extend the `LegalActionsCache` struct to also cache state transitions using (action, roll) tuples:
```cpp
// Key for transition cache: (actionIndex, roll)
struct TransitionKey {
size_t actionIndex;
int roll;
bool operator==(const TransitionKey& other) const {
return actionIndex == other.actionIndex && roll == other.roll;
}
};
// Hash function for TransitionKey
struct TransitionKeyHash {
size_t operator()(const TransitionKey& key) const {
return std::hash<size_t>{}(key.actionIndex) ^ (std::hash<int>{}(key.roll) << 1);
}
};
struct LegalActionsCache {
std::shared_ptr<ShardokEngine> engine; // Existing: cached engine
std::vector<size_t> filteredIndices; // Existing: filtered action indices
gtl::flat_hash_map<TransitionKey, uint64_t, TransitionKeyHash> actionResults; // NEW: (action, roll) -> next_state_hash
};
```
**Purpose:** Avoid re-applying actions (PostCommand calls) for (state, action, roll) tuples we've already evaluated.
**Why (action, roll) tuples?**
- Handles determinism explicitly: different rolls produce different next states
- Initial implementation uses roll=50 for all transitions
- Future-proof: supports chance nodes with multiple roll values (10, 30, 50, 70, 90)
- Architecturally cleaner than requiring PostCommand to always use the same roll
**Location:** Modify `ShardokGameEngine::applyAction()` (line 50-99 in ShardokGameEngine.cpp)
## How It Works
### During MCTS Exploration/Simulation:
1. **Before applying an action:**
- Look up current state hash in cache
- If found, check if `actionResults` contains the (action_index, roll) tuple we want to apply
- If yes, retrieve the next state's hash from the map
- Look up that hash in the cache to get the next state's engine directly
- **Skip PostCommand entirely** - we already know the result!
2. **When applying a new action:**
- Apply action normally via `engine.PostCommand(playerId, actionIndex, roll)`
- Hash the resulting state
- Store the mapping: `actionResults[{action_index, roll}] = next_state_hash`
- Store the next state in the cache (if not already present)
### Example Flow:
```cpp
// Current state hash: 0xABCD
// Want to apply action index 5 with roll 50
const int roll = 50; // Fixed roll for initial implementation
auto it = legalActionsCache_.find(0xABCD);
if (it != legalActionsCache_.end()) {
TransitionKey key{5, roll};
if (auto resIt = it->second.actionResults.find(key); resIt != it->second.actionResults.end()) {
// We've applied this (action, roll) before!
uint64_t nextHash = resIt->second;
if (auto nextIt = legalActionsCache_.find(nextHash); nextIt != legalActionsCache_.end()) {
// We have the complete next state cached
// Clone the cached engine and return - No PostCommand needed!
return createStateFromCachedEngine(nextIt->second.engine);
}
}
}
// Haven't seen this (state, action, roll) tuple before, apply normally
engine.PostCommand(playerId, 5, roll);
uint64_t nextHash = HashGameState(engine.GetCurrentGameState());
legalActionsCache_[0xABCD].actionResults[{5, roll}] = nextHash;
```
## Benefits
1. **Eliminates redundant PostCommand calls**
- PostCommand involves creating new GameStateW, potentially allocating memory
- Combat resolution, unit updates, state validation all skipped when cached
2. **Particularly valuable for MCTS**
- MCTS revisits states many times during tree search
- Same state-action pairs explored in multiple simulations
- Deeper trees mean more opportunities for cache hits
3. **Compounds with existing optimizations**
- Already caching score calculations
- Already caching available commands
- Now also caching state transitions
- All three together significantly reduce per-simulation cost
## Potential Issues & Solutions
### 1. Memory Usage
**Issue:** Storing (action, roll)->hash mappings for every visited state could consume significant memory.
**Mitigation:**
- Only store recently used entries (already done - thread-local cache per search)
- Cache is automatically cleared between searches
- Monitor memory usage in production
**Analysis:**
- Each cache entry: `TransitionKey{size_t actionIndex, int roll}` + `uint64_t nextHash`
- Size: ~24 bytes per entry (8 + 4 + 8, plus hash map overhead)
- For 10,000 states × 10 actions × 1 roll = 100,000 entries ≈ 2.4 MB
- With chance nodes (5 rolls per action): 10,000 × 10 × 5 = 500,000 entries ≈ 12 MB
- **Reasonable** for modern systems, especially since it's thread-local and cleared per search
### 2. Determinism Requirements
**Issue:** PostCommand results depend on the roll parameter, which affects combat outcomes and random events.
**ARCHITECTURAL SOLUTION:** Cache (action, roll) tuples instead of just actions!
```cpp
// Cache key includes BOTH action and roll
TransitionKey key{actionIndex, roll};
actionResults[key] = nextStateHash;
```
**Why this solves the problem:**
- Each (action, roll) combination gets its own cache entry
- If we call PostCommand(5, 50), we cache the result for (5, 50)
- If we later call PostCommand(5, 70), it's a different cache key - no collision!
- No need to enforce determinism at the PostCommand level
- Data structure naturally supports multiple rolls per action
**Initial Implementation:**
- Use fixed roll=50 for all transitions (matching IterativeDeepening)
- All cache entries will have roll=50
- Simple and deterministic
**Future Enhancement:**
- Implement chance nodes by exploring multiple rolls (10, 30, 50, 70, 90)
- Each roll becomes a separate child in the MCTS tree
- Cache naturally handles this: (action=5, roll=10), (action=5, roll=50), (action=5, roll=90) are distinct
- This models uncertainty without requiring code changes to the cache structure
**Required Changes:**
1. **Change PostCommand call in `applyAction()` (line 82):**
```cpp
// OLD:
engine->PostCommand(currentPlayer, shardokAction->getIndex(), nullptr);
// NEW:
const int roll = 50; // Fixed roll for initial implementation
engine->PostCommand(currentPlayer, shardokAction->getIndex(), roll);
```
2. **Change PostCommand call in `applyActionMutable()` (line 133):**
```cpp
// Same change - use roll=50
```
3. **Store the roll used when caching:**
```cpp
TransitionKey key{actionIndex, roll};
legalActionsCache_[currentStateHash].actionResults[key] = nextStateHash;
```
**Status:** ✅ SOLVED ARCHITECTURALLY - No determinism issues with this design.
### 3. Hash Collisions
**Issue:** Two different states might hash to the same value.
**Current situation:** Already a risk with existing transposition table.
**Mitigation:**
- Use 64-bit hashes (current implementation) - collision probability very low
- Could add verification: store state size/checksum alongside hash
- Could add debug mode that does full state comparison
### 4. Action Index Stability
**Issue:** Action indices must be stable (same action always has same index for a given state).
**Verification:**
- ShardokEngine::GetAvailableCommandProtos() must return actions in deterministic order
- Need to verify this is true
- If not, would need to hash actions themselves, not just use indices
**Risk:** LOW - game engine likely returns actions in consistent order
### 5. State Ownership & Copying
**Issue:** GameStateW contains FlatBufferBuilder, careful with copying/references.
**Solution:**
- TranspositionTable already stores complete ShardokEngine (which contains GameStateW)
- No additional complexity beyond existing implementation
- Just need to ensure we're cloning engines appropriately
## Implementation Plan
### Phase 1: Define TransitionKey and Extend Data Structure
**File:** `src/main/cpp/net/eagle0/shardok/ai/mcts/adapters/ShardokGameEngine.hpp`
1. **Add TransitionKey struct (before LegalActionsCache):**
```cpp
// Key for transition cache: (actionIndex, roll)
struct TransitionKey {
size_t actionIndex;
int roll;
bool operator==(const TransitionKey& other) const {
return actionIndex == other.actionIndex && roll == other.roll;
}
};
// Hash function for TransitionKey
struct TransitionKeyHash {
size_t operator()(const TransitionKey& key) const {
return std::hash<size_t>{}(key.actionIndex) ^ (std::hash<int>{}(key.roll) << 1);
}
};
```
2. **Update `LegalActionsCache` struct:**
```cpp
struct LegalActionsCache {
std::shared_ptr<ShardokEngine> engine;
std::vector<size_t> filteredIndices;
gtl::flat_hash_map<TransitionKey, uint64_t, TransitionKeyHash> actionResults; // NEW
};
```
3. **Add cache statistics fields:**
```cpp
// Add to class members:
thread_local static uint64_t transitionCacheHits_;
thread_local static uint64_t transitionCacheMisses_;
```
### Phase 2: Integrate with applyAction
**File:** `src/main/cpp/net/eagle0/shardok/ai/mcts/adapters/ShardokGameEngine.cpp`
Modify `ShardokGameEngine::applyAction()` (lines 50-99):
```cpp
std::unique_ptr<MCTSGameState> ShardokGameEngine::applyAction(
const MCTSGameState& state,
const MCTSAction& action) const {
const auto* shardokState = dynamic_cast<const ShardokGameState*>(&state);
const auto* shardokAction = dynamic_cast<const ShardokAction*>(&action);
if (!shardokState || !shardokAction) { return nullptr; }
const uint64_t currentStateHash = shardokState->hash();
const size_t actionIndex = shardokAction->getIndex();
const int roll = 50; // Fixed roll for initial implementation
// NEW: Check if we've already applied this (action, roll) to this state
TransitionKey key{actionIndex, roll};
if (auto it = legalActionsCache_.find(currentStateHash); it != legalActionsCache_.end()) {
if (auto resIt = it->second.actionResults.find(key); resIt != it->second.actionResults.end()) {
// We have the next state hash cached!
uint64_t nextStateHash = resIt->second;
// Look up the next state in the cache
if (auto nextIt = legalActionsCache_.find(nextStateHash); nextIt != legalActionsCache_.end()) {
// CACHE HIT: Clone the cached engine and return the state
transitionCacheHits_++;
auto engine = std::make_shared<ShardokEngine>(*nextIt->second.engine);
return std::make_unique<ShardokGameState>(
engine->GetCurrentGameState(),
scoreCalculator_,
gameSettings_.get(),
isDefender_,
strategy_,
castleCoords_,
*apdCache_,
*alCache_,
criticalTileCoords_);
}
}
}
// CACHE MISS: Apply action normally...
transitionCacheMisses_++;
// (existing code from lines 58-98, but change line 82:)
// OLD: engine->PostCommand(currentPlayer, shardokAction->getIndex(), nullptr);
// NEW: engine->PostCommand(currentPlayer, shardokAction->getIndex(), roll);
// After applying, store the transition:
uint64_t nextStateHash = newState->hash();
legalActionsCache_[currentStateHash].actionResults[key] = nextStateHash;
return newState;
}
```
**Similar changes for `applyActionMutable()`** (lines 100-150)
### Phase 3: Testing & Validation (Critical)
1. Add unit tests verifying:
- Cached transitions match actual transitions
- Performance improvement measurable
- No correctness regressions
2. Add debug assertions:
- Verify determinism: cached result == recomputed result (sample check)
- Detect hash collisions (optional, performance cost)
3. Add performance tracking:
- Count cache hits vs misses
- Measure time saved
- Monitor memory usage
### Phase 4: Optimization (Optional)
1. Tune cache eviction policy if memory becomes issue
2. Consider bloom filter to quickly reject cache misses
3. Profile to ensure cache lookups aren't dominating cost
## Performance Expectations
**Conservative estimate:**
- PostCommand might take ~10-50 microseconds (allocations, state updates)
- Hash table lookup takes ~100-500 nanoseconds
- If we get 30% cache hit rate, save ~3-15 microseconds per simulation step
- With 100,000 simulations, save 0.3-1.5 seconds per search
**Best case estimate:**
- In dense search trees, might get 70%+ cache hit rate
- Could save 5-10 seconds per search in complex positions
**Measurement needed:** Profile actual PostCommand cost and cache hit rates.
## Risks
**HIGH RISK:**
- Non-determinism in PostCommand would cause silent correctness bugs
- Must thoroughly test determinism before enabling in production
**MEDIUM RISK:**
- Memory usage could grow large in long-running games
- Need monitoring and eviction policy
**LOW RISK:**
- Implementation complexity moderate but manageable
- Can be feature-flagged and disabled if problems arise
## Recommendation
**This optimization appears sound IF PostCommand is deterministic.**
**Suggested approach:**
1. First, verify PostCommand determinism with extensive testing
2. Implement with feature flag (can disable if issues found)
3. Add comprehensive debug assertions
4. Profile to verify performance improvement justifies complexity
5. Monitor memory usage in production
**Key verification needed before proceeding:**
- Confirm PostCommand has no randomness
- Confirm action indices are stable
- Measure baseline PostCommand performance cost
## Alternative Considered: Lighter-Weight Caching
Instead of storing full state transitions, just cache:
```cpp
unordered_map<pair<uint64_t, size_t>, uint64_t> globalTransitionCache;
// Maps (state_hash, action_index) -> next_state_hash
```
**Pros:**
- Simpler data structure
- Easier to implement cache eviction
**Cons:**
- Requires two hash lookups per transition (this map, then transposition table)
- Doesn't integrate as cleanly with existing transposition table
**Verdict:** Proposed approach (extending LegalActionsCache) is cleaner and integrates with existing infrastructure.
## Files to Modify
### Phase 1: Data Structure
1. **`src/main/cpp/net/eagle0/shardok/ai/mcts/adapters/ShardokGameEngine.hpp`**
- Add `TransitionKey` struct with equality operator
- Add `TransitionKeyHash` struct for hashing
- Modify `LegalActionsCache` to use `gtl::flat_hash_map<TransitionKey, uint64_t, TransitionKeyHash> actionResults;`
- Add thread-local statistics: `transitionCacheHits_`, `transitionCacheMisses_`
### Phase 2: Implementation
2. **`src/main/cpp/net/eagle0/shardok/ai/mcts/adapters/ShardokGameEngine.cpp`**
- Initialize thread-local statistics variables
- Modify `applyAction()`:
- Line 82: Change `nullptr` to `50` for roll parameter
- Add transition cache lookup using `TransitionKey{actionIndex, 50}`
- Add transition cache storage after PostCommand
- Modify `applyActionMutable()`:
- Line 133: Change `nullptr` to `50` for roll parameter
- Add same transition cache logic
- Update `reportCacheStatistics()` to include transition cache hit/miss stats
### Phase 3: Testing
3. **`src/test/cpp/net/eagle0/shardok/ai/mcts/ShardokMCTSAI_test.cpp`** (or create new test file)
- Add unit tests for determinism verification (same search → same result)
- Add unit tests for transition cache correctness
- Add performance benchmarks comparing with/without transition cache
- Test with multiple rolls to verify tuple caching works correctly
## Related Work
This optimization is related to:
- **Zobrist hashing** in chess engines (incremental hash updates)
- **Transposition tables with move ordering** in minimax search
- **Memoization** in dynamic programming
Shardok's approach is most similar to transposition tables in chess engines, but applied to MCTS instead of minimax.
+21 -137
View File
@@ -13,8 +13,8 @@ AWS_SDK_VERSION = "2.28.1"
# Core Build Tools
#
bazel_dep(name = "bazel_skylib", version = "1.9.0")
bazel_dep(name = "rules_pkg", version = "1.2.0")
bazel_dep(name = "bazel_skylib", version = "1.8.1")
bazel_dep(name = "rules_pkg", version = "1.1.0")
#
# Language Support - Scala
@@ -26,99 +26,56 @@ scala_config = use_extension(
"@rules_scala//scala/extensions:config.bzl",
"scala_config",
)
scala_config.settings(scala_version = SCALA_VERSION)
scala_deps = use_extension(
"@rules_scala//scala/extensions:deps.bzl",
"scala_deps",
)
scala_deps.scala()
scala_deps.scalatest()
scala_deps.scala_proto()
#
# Language Support - C++
#
bazel_dep(name = "toolchains_llvm", version = "1.6.0")
bazel_dep(name = "toolchains_llvm", version = "1.4.0")
llvm = use_extension("@toolchains_llvm//toolchain/extensions:llvm.bzl", "llvm")
# Native toolchain (macOS -> macOS, Linux -> Linux)
llvm.toolchain(
name = "llvm_toolchain",
llvm_version = "20.1.2",
)
# Cross-compilation toolchain (macOS -> Linux x86_64)
# Uses the same LLVM distribution but with a Linux sysroot
llvm.toolchain(
name = "llvm_toolchain_linux",
llvm_version = "20.1.2",
)
# Linux x86_64 sysroot for cross-compilation
llvm.sysroot(
name = "llvm_toolchain_linux",
label = "@linux_sysroot//sysroot",
targets = ["linux-x86_64"],
)
# Cross-compilation toolchain (macOS -> Linux ARM64)
llvm.toolchain(
name = "llvm_toolchain_linux_arm64",
llvm_version = "20.1.2",
)
# Linux ARM64 sysroot for cross-compilation
llvm.sysroot(
name = "llvm_toolchain_linux_arm64",
label = "@linux_sysroot_arm64//sysroot",
targets = ["linux-aarch64"],
)
use_repo(llvm, "llvm_toolchain", "llvm_toolchain_linux", "llvm_toolchain_linux_arm64")
# Download the Linux sysroots (Ubuntu 24.04 Noble for C++23 support)
# Built by: .github/workflows/build_sysroot.yml
# To rebuild: Run the "Build Linux Sysroot" workflow with a new version, then update sha256 and URL
sysroot = use_repo_rule("@toolchains_llvm//toolchain:sysroot.bzl", "sysroot")
# x86_64 sysroot
sysroot(
name = "linux_sysroot",
sha256 = "a06475004fe8003ae7ccb4fe1d5511feb9b27cce4a8826eb1dfd686ed83f3dba",
urls = ["https://eagle0-sysroot.sfo3.digitaloceanspaces.com/v3/ubuntu_noble_amd64_sysroot.tar.xz"],
)
# ARM64 sysroot
sysroot(
name = "linux_sysroot_arm64",
sha256 = "87469137737e09bc73855007dab835477eb10a7b3ce3f725f93f64e25747f3f9",
urls = ["https://eagle0-sysroot.sfo3.digitaloceanspaces.com/v4/ubuntu_noble_arm64_sysroot.tar.xz"],
)
use_repo(llvm, "llvm_toolchain")
#
# Language Support - Go
#
bazel_dep(name = "rules_go", version = "0.59.0", repo_name = "io_bazel_rules_go")
bazel_dep(name = "gazelle", version = "0.47.0", repo_name = "bazel_gazelle")
bazel_dep(name = "rules_go", repo_name = "io_bazel_rules_go", version = "0.56.1")
bazel_dep(name = "gazelle", repo_name = "bazel_gazelle", version = "0.45.0")
go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk")
go_sdk.download(version = "1.23.3")
go_deps = use_extension("@bazel_gazelle//:extensions.bzl", "go_deps")
go_deps.from_file(go_mod = "//:go.mod")
use_repo(
go_deps,
"com_github_aws_aws_sdk_go_v2",
"com_github_aws_aws_sdk_go_v2_config",
"com_github_aws_aws_sdk_go_v2_credentials",
"com_github_aws_aws_sdk_go_v2_service_s3",
"com_github_golang_jwt_jwt_v5",
"com_github_google_uuid",
"org_golang_google_grpc",
"org_golang_google_protobuf",
)
@@ -126,18 +83,18 @@ use_repo(
# Platform Support - Apple/iOS
#
bazel_dep(name = "apple_support", version = "1.21.1", repo_name = "build_bazel_apple_support")
bazel_dep(name = "rules_apple", version = "4.3.3", repo_name = "build_bazel_rules_apple")
bazel_dep(name = "rules_swift", version = "2.4.0", repo_name = "build_bazel_rules_swift")
bazel_dep(name = "apple_support", repo_name = "build_bazel_apple_support", version = "1.21.1")
bazel_dep(name = "rules_apple", repo_name = "build_bazel_rules_apple", version = "3.16.1")
bazel_dep(name = "rules_swift", repo_name = "build_bazel_rules_swift", version = "2.3.1")
#
# Protocol Buffers & RPC
#
bazel_dep(name = "protobuf", version = "29.2", repo_name = "com_google_protobuf")
bazel_dep(name = "protobuf", repo_name = "com_google_protobuf", version = "29.2")
bazel_dep(name = "grpc", version = "1.71.0")
bazel_dep(name = "grpc-java", version = "1.71.0")
bazel_dep(name = "flatbuffers", version = "25.9.23")
bazel_dep(name = "flatbuffers", version = "25.2.10")
#
# Testing
@@ -145,50 +102,14 @@ bazel_dep(name = "flatbuffers", version = "25.9.23")
bazel_dep(name = "googletest", version = "1.17.0")
#
# Container Images (OCI)
#
bazel_dep(name = "rules_oci", version = "2.2.7")
bazel_dep(name = "aspect_bazel_lib", version = "2.22.4")
oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
# Base image for Eagle (Java 17 JDK - includes jcmd for JFR dumps)
oci.pull(
name = "eclipse_temurin_17",
image = "docker.io/library/eclipse-temurin",
platforms = ["linux/amd64"],
tag = "17-jdk",
)
# Base image for Shardok (Ubuntu 24.04 for C++ runtime)
oci.pull(
name = "ubuntu_24_04",
image = "docker.io/library/ubuntu",
platforms = [
"linux/amd64",
"linux/arm64/v8",
],
tag = "24.04",
)
# Base image for Admin Server (Alpine for lightweight Go binary)
oci.pull(
name = "alpine_linux",
image = "docker.io/library/alpine",
platforms = ["linux/amd64"],
tag = "3.21",
)
use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_17", "eclipse_temurin_17_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64", "ubuntu_24_04_linux_arm64_v8")
#
# Java/Scala Dependencies
#
bazel_dep(name = "rules_jvm_external", version = "6.9")
bazel_dep(name = "rules_jvm_external", version = "6.3")
maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")
maven.install(
artifacts = [
# Netty
@@ -239,16 +160,6 @@ maven.install(
# Other
"org.reactivestreams:reactive-streams:1.0.4",
"javax.xml.bind:jaxb-api:2.3.1",
# OkHttp (for SSE with read timeout support, OAuth HTTP calls)
"com.squareup.okhttp3:okhttp:4.12.0",
"com.squareup.okhttp3:okhttp-sse:4.12.0",
# JWT (for OAuth token handling)
"com.nimbusds:nimbus-jose-jwt:9.37.3",
# Error tracking
"io.sentry:sentry:7.19.0",
],
duplicate_version_warning = "error",
fail_if_repin_required = True,
@@ -257,6 +168,7 @@ maven.install(
"https://repo1.maven.org/maven2",
],
)
use_repo(maven, "maven", "unpinned_maven")
#
@@ -264,7 +176,6 @@ use_repo(maven, "maven", "unpinned_maven")
#
http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file")
# GTL (for parallel_hashmap)
GTL_VERSION = "1.2.0"
@@ -293,31 +204,6 @@ http_archive(
],
)
# Busybox static binary for Docker health checks (provides nc, wget, etc.)
# Primary: DigitalOcean Spaces (public, reliable)
# Fallback: busybox.net (can be unreliable/slow)
http_file(
name = "busybox_x86_64",
sha256 = "6e123e7f3202a8c1e9b1f94d8941580a25135382b99e8d3e34fb858bba311348",
urls = [
"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/busybox/busybox-1.35.0-x86_64-linux-musl",
"https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox",
],
downloaded_file_path = "busybox",
executable = True,
)
http_file(
name = "busybox_aarch64",
sha256 = "141adb1b625a6f44c4b114f76b4387b4ea4f7ab802b88eb40e0d2f6adcccb1c3",
urls = [
# TODO: Upload aarch64 binary to GitHub release when needed
"https://busybox.net/downloads/binaries/1.35.0-aarch64-linux-musl/busybox",
],
downloaded_file_path = "busybox",
executable = True,
)
#
# Toolchain Registration
#
@@ -330,7 +216,5 @@ register_toolchains(
# Set dev_dependency so we can turn this off for swift MacOS builds
register_toolchains(
"@llvm_toolchain//:all",
"@llvm_toolchain_linux//:all",
"@llvm_toolchain_linux_arm64//:all",
dev_dependency = True,
)
+110 -435
View File
@@ -26,11 +26,7 @@
"https://bcr.bazel.build/modules/aspect_bazel_lib/1.38.0/MODULE.bazel": "6307fec451ba9962c1c969eb516ebfe1e46528f7fa92e1c9ac8646bef4cdaa3f",
"https://bcr.bazel.build/modules/aspect_bazel_lib/1.40.3/MODULE.bazel": "668e6bcb4d957fc0e284316dba546b705c8d43c857f87119619ee83c4555b859",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.11.0/MODULE.bazel": "cb1ba9f9999ed0bc08600c221f532c1ddd8d217686b32ba7d45b0713b5131452",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.14.0/MODULE.bazel": "2b31ffcc9bdc8295b2167e07a757dbbc9ac8906e7028e5170a3708cecaac119f",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/MODULE.bazel": "253d739ba126f62a5767d832765b12b59e9f8d2bc88cc1572f4a73e46eb298ca",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.22.4/MODULE.bazel": "a05cbd9bc16712a58dc27ffe0dceaefd0da59a9bd87a227379b2a934b26a39ab",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.22.4/source.json": "9780bc57f521968ee82b7c3e85b7d0c71518fb7ce83ed7a9e5077ce20923207b",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.2/MODULE.bazel": "780d1a6522b28f5edb7ea09630748720721dfe27690d65a2d33aa7509de77e07",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.11.0/source.json": "92494d5aa43b96665397dd13ee16023097470fa85e276b93674d62a244de47ee",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.7/MODULE.bazel": "491f8681205e31bb57892d67442ce448cda4f472a8e6b3dc062865e29a64f89c",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.8.1/MODULE.bazel": "812d2dd42f65dca362152101fbec418029cc8fd34cbad1a2fde905383d705838",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.9.3/MODULE.bazel": "66baf724dbae7aff4787bf2245cc188d50cb08e07789769730151c0943587c14",
@@ -39,11 +35,11 @@
"https://bcr.bazel.build/modules/aspect_rules_js/1.33.1/MODULE.bazel": "db3e7f16e471cf6827059d03af7c21859e7a0d2bc65429a3a11f005d46fc501b",
"https://bcr.bazel.build/modules/aspect_rules_js/1.39.0/MODULE.bazel": "aece421d479e3c31dc3e5f6d49a12acc2700457c03c556650ec7a0ff23fc0d95",
"https://bcr.bazel.build/modules/aspect_rules_js/2.0.0/MODULE.bazel": "b45b507574aa60a92796e3e13c195cd5744b3b8aff516a9c0cb5ae6a048161c5",
"https://bcr.bazel.build/modules/aspect_rules_js/2.3.8/MODULE.bazel": "74bf20a7a6bd5f2be09607fdb4196cfd6f203422ea271752ec2b1afe95426101",
"https://bcr.bazel.build/modules/aspect_rules_js/2.3.8/source.json": "411ec9d79d6f5fe8a083359588c21d01a5b48d88a2cbd334a4c90365015b7836",
"https://bcr.bazel.build/modules/aspect_rules_js/2.1.3/MODULE.bazel": "47cc48eec374d69dced3cf9b9e5926beac2f927441acfb1a3568bbb709b25666",
"https://bcr.bazel.build/modules/aspect_rules_js/2.1.3/source.json": "6b0fe67780c101430be087381b7a79d75eeebe1a1eae6a2cee937713603634ac",
"https://bcr.bazel.build/modules/aspect_rules_lint/0.12.0/MODULE.bazel": "e767c5dbfeb254ec03275a7701b5cfde2c4d2873676804bc7cb27ddff3728fed",
"https://bcr.bazel.build/modules/aspect_rules_ts/3.6.0/MODULE.bazel": "d0045b5eabb012be550a609589b3e5e47eba682344b19cfd9365d4d896ed07df",
"https://bcr.bazel.build/modules/aspect_rules_ts/3.6.0/source.json": "5593e3f1cd0dd5147f7748e163307fd5c2e1077913d6945b58739ad8d770a290",
"https://bcr.bazel.build/modules/aspect_rules_ts/3.4.0/MODULE.bazel": "5b554d5de90d96ee14117527c0519037713dd33884f3212eae391beccb2e94ff",
"https://bcr.bazel.build/modules/aspect_rules_ts/3.4.0/source.json": "9ada3722b716853b6dccdb7b650d8e776a23bc8a190de0c59bd15f21afea6f8a",
"https://bcr.bazel.build/modules/bazel_features/0.1.0/MODULE.bazel": "47011d645b0f949f42ee67f2e8775188a9cf4a0a1528aa2fa4952f2fd00906fd",
"https://bcr.bazel.build/modules/bazel_features/1.1.0/MODULE.bazel": "cfd42ff3b815a5f39554d97182657f8c4b9719568eb7fded2b9135f084bf760b",
"https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd",
@@ -55,17 +51,11 @@
"https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58",
"https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b",
"https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65",
"https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d",
"https://bcr.bazel.build/modules/bazel_features/1.27.0/source.json": "ed8cf0ef05c858dce3661689d0a2b110ff398e63994e178e4f1f7555a8067fed",
"https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9",
"https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87",
"https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e",
"https://bcr.bazel.build/modules/bazel_features/1.38.0/MODULE.bazel": "f9b8a9c890ebd216b4049fd12a31d3c2602e3403c7af636b04fbbd7453edc9c9",
"https://bcr.bazel.build/modules/bazel_features/1.38.0/source.json": "31ba776c122b54a2885e23651642e32f087a87bf025465f8040751894b571277",
"https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7",
"https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b",
"https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a",
"https://bcr.bazel.build/modules/bazel_lib/3.0.0/MODULE.bazel": "22b70b80ac89ad3f3772526cd9feee2fa412c2b01933fea7ed13238a448d370d",
"https://bcr.bazel.build/modules/bazel_lib/3.0.0/source.json": "895f21909c6fba01d7c17914bb6c8e135982275a1b18cdaa4e62272217ef1751",
"https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8",
"https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e",
"https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686",
@@ -79,9 +69,7 @@
"https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d",
"https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67",
"https://bcr.bazel.build/modules/bazel_skylib/1.9.0/MODULE.bazel": "72997b29dfd95c3fa0d0c48322d05590418edef451f8db8db5509c57875fb4b7",
"https://bcr.bazel.build/modules/bazel_skylib/1.9.0/source.json": "7ad77c1e8c1b84222d9b3f3cae016a76639435744c19330b0b37c0a3c9da7dc0",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.1/source.json": "7ebaefba0b03efe59cac88ed5bbc67bcf59a3eff33af937345ede2a38b2d368a",
"https://bcr.bazel.build/modules/bazel_worker_api/0.0.6/MODULE.bazel": "fd1f9432ca04c947e91b500df69ce7c5b6dbfe1bc45ab1820338205dae3383a6",
"https://bcr.bazel.build/modules/bazel_worker_api/0.0.6/source.json": "5d68545f224904745a3cabd35aea6bc2b6cc5a78b7f49f3f69660eab2eeeb273",
"https://bcr.bazel.build/modules/boringssl/0.0.0-20211025-d4f1ab9/MODULE.bazel": "6ee6353f8b1a701fe2178e1d925034294971350b6d3ac37e67e5a7d463267834",
@@ -108,10 +96,8 @@
"https://bcr.bazel.build/modules/envoy_api/0.0.0-20241214-918efc9/MODULE.bazel": "24e05f6f52f37be63a795192848555a2c8c855e7814dbc1ed419fb04a7005464",
"https://bcr.bazel.build/modules/envoy_api/0.0.0-20250128-4de3c74/MODULE.bazel": "1fe72489212c530086e3ffb0e018b2bfef4663200ca03571570f9f006bef1d75",
"https://bcr.bazel.build/modules/envoy_api/0.0.0-20250128-4de3c74/source.json": "028519164a2e24563f4b43d810fdedc702daed90e71e7042d45ba82ad807b46f",
"https://bcr.bazel.build/modules/flatbuffers/25.9.23/MODULE.bazel": "32753ba60bf3bacfe7737c0f3e8e3e55624b19af5d398c485580d57492d145d8",
"https://bcr.bazel.build/modules/flatbuffers/25.9.23/source.json": "a2116f0017f6896353fd3abf65ef2b89b0a257e8a87f395c5000f53934829f31",
"https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/MODULE.bazel": "cdf8cbe5ee750db04b78878c9633cc76e80dcf4416cbe982ac3a9222f80713c8",
"https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/source.json": "fa7b512dfcb5eafd90ce3959cf42a2a6fe96144ebbb4b3b3928054895f2afac2",
"https://bcr.bazel.build/modules/flatbuffers/25.2.10/MODULE.bazel": "dab15cafe8512d2c4a8daa44c2d7968c5c79f01e220d40076cdc260bf58605e2",
"https://bcr.bazel.build/modules/flatbuffers/25.2.10/source.json": "7eae7ea3eb913b9802426e4d5df11d6c6072a3573a548f8cabf1e965f5cca4d0",
"https://bcr.bazel.build/modules/gazelle/0.27.0/MODULE.bazel": "3446abd608295de6d90b4a8a118ed64a9ce11dcb3dda2dc3290a22056bd20996",
"https://bcr.bazel.build/modules/gazelle/0.30.0/MODULE.bazel": "f888a1effe338491f35f0e0e85003b47bb9d8295ccba73c37e07702d8d31c65b",
"https://bcr.bazel.build/modules/gazelle/0.32.0/MODULE.bazel": "b499f58a5d0d3537f3cf5b76d8ada18242f64ec474d8391247438bf04f58c7b8",
@@ -119,8 +105,8 @@
"https://bcr.bazel.build/modules/gazelle/0.34.0/MODULE.bazel": "abdd8ce4d70978933209db92e436deb3a8b737859e9354fb5fd11fb5c2004c8a",
"https://bcr.bazel.build/modules/gazelle/0.36.0/MODULE.bazel": "e375d5d6e9a6ca59b0cb38b0540bc9a05b6aa926d322f2de268ad267a2ee74c0",
"https://bcr.bazel.build/modules/gazelle/0.37.0/MODULE.bazel": "d1327ba0907d0275ed5103bfbbb13518f6c04955b402213319d0d6c0ce9839d4",
"https://bcr.bazel.build/modules/gazelle/0.47.0/MODULE.bazel": "b61bb007c4efad134aa30ee7f4a8e2a39b22aa5685f005edaa022fbd1de43ebc",
"https://bcr.bazel.build/modules/gazelle/0.47.0/source.json": "aeb2e5df14b7fb298625d75d08b9c65bdb0b56014c5eb89da9e5dd0572280ae6",
"https://bcr.bazel.build/modules/gazelle/0.45.0/MODULE.bazel": "ecd19ebe9f8e024e1ccffb6d997cc893a974bcc581f1ae08f386bdd448b10687",
"https://bcr.bazel.build/modules/gazelle/0.45.0/source.json": "111d182facc5f5e80f0b823d5f077b74128f40c3fd2eccc89a06f34191bd3392",
"https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb",
"https://bcr.bazel.build/modules/google_benchmark/1.8.4/MODULE.bazel": "c6d54a11dcf64ee63545f42561eda3fd94c1b5f5ebe1357011de63ae33739d5e",
"https://bcr.bazel.build/modules/google_benchmark/1.8.5/MODULE.bazel": "9ba9b31b984022828a950e3300410977eda2e35df35584c6b0b2d0c2e52766b7",
@@ -151,10 +137,6 @@
"https://bcr.bazel.build/modules/grpc/1.70.1/MODULE.bazel": "b800cd8e3e7555c1e61cba2e02d3a2fcf0e91f66e800db286d965d3b7a6a721a",
"https://bcr.bazel.build/modules/grpc/1.71.0/MODULE.bazel": "7fcab2c05530373f1a442c362b17740dd0c75b6a2a975eec8f5bf4c70a37928a",
"https://bcr.bazel.build/modules/grpc/1.71.0/source.json": "60ef8c4c72c8280ae94c05b4f38bf67785acb25477ab8dbac096a9604449ff90",
"https://bcr.bazel.build/modules/helly25_bzl/0.3.1/MODULE.bazel": "3a4be20f6fc13be32ad44643b8252ef5af09eee936f1d943cd4fd7867fa92826",
"https://bcr.bazel.build/modules/helly25_bzl/0.3.1/source.json": "b129ab1828492de2c163785bbeb4065c166de52d932524b4317beb5b7f917994",
"https://bcr.bazel.build/modules/jq.bzl/0.1.0/MODULE.bazel": "2ce69b1af49952cd4121a9c3055faa679e748ce774c7f1fda9657f936cae902f",
"https://bcr.bazel.build/modules/jq.bzl/0.1.0/source.json": "746bf13cac0860f091df5e4911d0c593971cd8796b5ad4e809b2f8e133eee3d5",
"https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075",
"https://bcr.bazel.build/modules/jsoncpp/1.9.5/source.json": "4108ee5085dd2885a341c7fab149429db457b3169b86eb081fa245eadf69169d",
"https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902",
@@ -179,8 +161,6 @@
"https://bcr.bazel.build/modules/opentelemetry-proto/1.5.0/source.json": "046b721ce203e88cdaad44d7dd17a86b7200eab9388b663b234e72e13ff7b143",
"https://bcr.bazel.build/modules/opentracing-cpp/1.6.0/MODULE.bazel": "b3925269f63561b8b880ae7cf62ccf81f6ece55b62cd791eda9925147ae116ec",
"https://bcr.bazel.build/modules/opentracing-cpp/1.6.0/source.json": "da1cb1add160f5e5074b7272e9db6fd8f1b3336c15032cd0a653af9d2f484aed",
"https://bcr.bazel.build/modules/package_metadata/0.0.2/MODULE.bazel": "fb8d25550742674d63d7b250063d4580ca530499f045d70748b1b142081ebb92",
"https://bcr.bazel.build/modules/package_metadata/0.0.3/MODULE.bazel": "77890552ecea9e284b5424c9de827a58099348763a4359e975c359a83d4faa83",
"https://bcr.bazel.build/modules/package_metadata/0.0.5/MODULE.bazel": "ef4f9439e3270fdd6b9fd4dbc3d2f29d13888e44c529a1b243f7a31dfbc2e8e4",
"https://bcr.bazel.build/modules/package_metadata/0.0.5/source.json": "2326db2f6592578177751c3e1f74786b79382cd6008834c9d01ec865b9126a85",
"https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5",
@@ -234,9 +214,9 @@
"https://bcr.bazel.build/modules/rules_android/0.1.1/MODULE.bazel": "48809ab0091b07ad0182defb787c4c5328bd3a278938415c00a7b69b50c4d3a8",
"https://bcr.bazel.build/modules/rules_android/0.1.1/source.json": "e6986b41626ee10bdc864937ffb6d6bf275bb5b9c65120e6137d56e6331f089e",
"https://bcr.bazel.build/modules/rules_apple/3.16.0/MODULE.bazel": "0d1caf0b8375942ce98ea944be754a18874041e4e0459401d925577624d3a54a",
"https://bcr.bazel.build/modules/rules_apple/3.16.1/MODULE.bazel": "8294474defa70af2534a558ab905c083d69203344145e6f7d544d5098611ec7d",
"https://bcr.bazel.build/modules/rules_apple/3.16.1/source.json": "9190fd9d34a5d048bfbba8a530a57f2c2bf3f61e5634a9ab0b6ab005458857f9",
"https://bcr.bazel.build/modules/rules_apple/3.5.1/MODULE.bazel": "3d1bbf65ad3692003d36d8a29eff54d4e5c1c5f4bfb60f79e28646a924d9101c",
"https://bcr.bazel.build/modules/rules_apple/4.3.3/MODULE.bazel": "c5c2c4adeeac5f3f2f9b7f16abfa8be7ffefa596171d0d92bed4cae9ade0a498",
"https://bcr.bazel.build/modules/rules_apple/4.3.3/source.json": "3cb1d69c8243ffcc42ecbf84ae8b9cccd7b1e2f091b0aee5a3e9c9a45267f312",
"https://bcr.bazel.build/modules/rules_buf/0.1.1/MODULE.bazel": "6189aec18a4f7caff599ad41b851ab7645d4f1e114aa6431acf9b0666eb92162",
"https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647",
"https://bcr.bazel.build/modules/rules_cc/0.0.10/MODULE.bazel": "ec1705118f7eaedd6e118508d3d26deba2a4e76476ada7e0e3965211be012002",
@@ -245,16 +225,12 @@
"https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc",
"https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87",
"https://bcr.bazel.build/modules/rules_cc/0.0.17/MODULE.bazel": "2ae1d8f4238ec67d7185d8861cb0a2cdf4bc608697c331b95bf990e69b62e64a",
"https://bcr.bazel.build/modules/rules_cc/0.0.17/source.json": "4db99b3f55c90ab28d14552aa0632533e3e8e5e9aea0f5c24ac0014282c2a7c5",
"https://bcr.bazel.build/modules/rules_cc/0.0.2/MODULE.bazel": "6915987c90970493ab97393024c156ea8fb9f3bea953b2f3ec05c34f19b5695c",
"https://bcr.bazel.build/modules/rules_cc/0.0.5/MODULE.bazel": "be41f87587998fe8890cd82ea4e848ed8eb799e053c224f78f3ff7fe1a1d9b74",
"https://bcr.bazel.build/modules/rules_cc/0.0.6/MODULE.bazel": "abf360251023dfe3efcef65ab9d56beefa8394d4176dd29529750e1c57eaa33f",
"https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e",
"https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5",
"https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513",
"https://bcr.bazel.build/modules/rules_cc/0.1.2/MODULE.bazel": "557ddc3a96858ec0d465a87c0a931054d7dcfd6583af2c7ed3baf494407fd8d0",
"https://bcr.bazel.build/modules/rules_cc/0.1.5/MODULE.bazel": "88dfc9361e8b5ae1008ac38f7cdfd45ad738e4fa676a3ad67d19204f045a1fd8",
"https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8",
"https://bcr.bazel.build/modules/rules_cc/0.2.14/source.json": "55d0a4587c5592fad350f6e698530f4faf0e7dd15e69d43f8d87e220c78bea54",
"https://bcr.bazel.build/modules/rules_foreign_cc/0.10.1/MODULE.bazel": "b9527010e5fef060af92b6724edb3691970a5b1f76f74b21d39f7d433641be60",
"https://bcr.bazel.build/modules/rules_foreign_cc/0.10.1/source.json": "9300e71df0cdde0952f10afff1401fa664e9fc5d9ae6204660ba1b158d90d6a6",
"https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6",
@@ -270,8 +246,8 @@
"https://bcr.bazel.build/modules/rules_go/0.48.0/MODULE.bazel": "d00ebcae0908ee3f5e6d53f68677a303d6d59a77beef879598700049c3980a03",
"https://bcr.bazel.build/modules/rules_go/0.50.1/MODULE.bazel": "b91a308dc5782bb0a8021ad4330c81fea5bda77f96b9e4c117b9b9c8f6665ee0",
"https://bcr.bazel.build/modules/rules_go/0.53.0/MODULE.bazel": "a4ed760d3ac0dbc0d7b967631a9a3fd9100d28f7d9fcf214b4df87d4bfff5f9a",
"https://bcr.bazel.build/modules/rules_go/0.59.0/MODULE.bazel": "b7e43e7414a3139a7547d1b4909b29085fbe5182b6c58cbe1ed4c6272815aeae",
"https://bcr.bazel.build/modules/rules_go/0.59.0/source.json": "1df17bb7865cfc029492c30163cee891d0dd8658ea0d5bfdf252c4b6db5c1ef6",
"https://bcr.bazel.build/modules/rules_go/0.56.1/MODULE.bazel": "d5b835c548ac917345f1780cd2da52edc1130a908fe091c92096895303ae78a0",
"https://bcr.bazel.build/modules/rules_go/0.56.1/source.json": "0c902f7272e8d4e47e459af97be472bc19dadbbe6023a0719d1adce8483ac75a",
"https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74",
"https://bcr.bazel.build/modules/rules_java/5.1.0/MODULE.bazel": "324b6478b0343a3ce7a9add8586ad75d24076d6d43d2f622990b9c1cfd8a1b15",
"https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86",
@@ -299,8 +275,7 @@
"https://bcr.bazel.build/modules/rules_jvm_external/6.0/MODULE.bazel": "37c93a5a78d32e895d52f86a8d0416176e915daabd029ccb5594db422e87c495",
"https://bcr.bazel.build/modules/rules_jvm_external/6.1/MODULE.bazel": "75b5fec090dbd46cf9b7d8ea08cf84a0472d92ba3585b476f44c326eda8059c4",
"https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0",
"https://bcr.bazel.build/modules/rules_jvm_external/6.9/MODULE.bazel": "07c5db05527db7744a54fcffd653e1550d40e0540207a7f7e6d0a4de5bef8274",
"https://bcr.bazel.build/modules/rules_jvm_external/6.9/source.json": "b12970214f3cc144b26610caeb101fa622d910f1ab3d98f0bae1058edbd00bd4",
"https://bcr.bazel.build/modules/rules_jvm_external/6.3/source.json": "6f5f5a5a4419ae4e37c35a5bb0a6ae657ed40b7abc5a5189111b47fcebe43197",
"https://bcr.bazel.build/modules/rules_kotlin/1.9.0/MODULE.bazel": "ef85697305025e5a61f395d4eaede272a5393cee479ace6686dba707de804d59",
"https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3",
"https://bcr.bazel.build/modules/rules_kotlin/1.9.6/source.json": "2faa4794364282db7c06600b7e5e34867a564ae91bda7cae7c29c64e9466b7d5",
@@ -314,12 +289,10 @@
"https://bcr.bazel.build/modules/rules_nodejs/6.3.0/MODULE.bazel": "45345e4aba35dd6e4701c1eebf5a4e67af4ed708def9ebcdc6027585b34ee52d",
"https://bcr.bazel.build/modules/rules_nodejs/6.3.3/MODULE.bazel": "b66eadebd10f1f1b25f52f95ab5213a57e82c37c3f656fcd9a57ad04d2264ce7",
"https://bcr.bazel.build/modules/rules_nodejs/6.3.3/source.json": "45bd343155bdfed2543f0e39b80ff3f6840efc31975da4b5795797f4c94147ad",
"https://bcr.bazel.build/modules/rules_oci/2.2.7/MODULE.bazel": "f6150e4b224d459f7f6523ef65967464ca4efdd266c7fbf2f5a2a51011957e0c",
"https://bcr.bazel.build/modules/rules_oci/2.2.7/source.json": "b099f02af330f47f19dc67fc9300ef6e1937a8c86882690db0e7a2fcea8c7f6b",
"https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc",
"https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff",
"https://bcr.bazel.build/modules/rules_pkg/1.2.0/MODULE.bazel": "c7db3c2b407e673c7a39e3625dc05dc9f12d6682cbd82a3a5924a13b491eda7e",
"https://bcr.bazel.build/modules/rules_pkg/1.2.0/source.json": "9062e00845bf91a4247465d371baa837adf9b6ff44c542f73ba084f07667e1dc",
"https://bcr.bazel.build/modules/rules_pkg/1.1.0/MODULE.bazel": "9db8031e71b6ef32d1846106e10dd0ee2deac042bd9a2de22b4761b0c3036453",
"https://bcr.bazel.build/modules/rules_pkg/1.1.0/source.json": "fef768df13a92ce6067e1cd0cdc47560dace01354f1d921cfb1d632511f7d608",
"https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06",
"https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7",
"https://bcr.bazel.build/modules/rules_proto/6.0.0-rc1/MODULE.bazel": "1e5b502e2e1a9e825eef74476a5a1ee524a92297085015a052510b09a1a09483",
@@ -342,20 +315,18 @@
"https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c",
"https://bcr.bazel.build/modules/rules_python/0.40.0/MODULE.bazel": "9d1a3cd88ed7d8e39583d9ffe56ae8a244f67783ae89b60caafc9f5cf318ada7",
"https://bcr.bazel.build/modules/rules_python/1.0.0/MODULE.bazel": "898a3d999c22caa585eb062b600f88654bf92efb204fa346fb55f6f8edffca43",
"https://bcr.bazel.build/modules/rules_python/1.3.0/MODULE.bazel": "8361d57eafb67c09b75bf4bbe6be360e1b8f4f18118ab48037f2bd50aa2ccb13",
"https://bcr.bazel.build/modules/rules_python/1.3.0/source.json": "25932f917cd279c7baefa6cb1d3fa8750a7a29de522024449b19af6eab51f4a0",
"https://bcr.bazel.build/modules/rules_python/1.0.0/source.json": "b0162a65c6312e45e7912e39abd1a7f8856c2c7e41ecc9b6dc688a6f6400a917",
"https://bcr.bazel.build/modules/rules_rust/0.45.1/MODULE.bazel": "a69d0db3a958fab2c6520961e1b2287afcc8b36690fd31bbc4f6f7391397150d",
"https://bcr.bazel.build/modules/rules_scala/7.1.1/MODULE.bazel": "b1f80c52ae49b27d41b9291d8b328b69247de2b7596d35d09afe6147b82cf562",
"https://bcr.bazel.build/modules/rules_scala/7.1.1/source.json": "5038cb231d4020c5965c920681cf961a7bf137b40315025e40f3a7b6a0ac1f0f",
"https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c",
"https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b",
"https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592",
"https://bcr.bazel.build/modules/rules_shell/0.4.1/source.json": "4757bd277fe1567763991c4425b483477bb82e35e777a56fd846eb5cceda324a",
"https://bcr.bazel.build/modules/rules_shell/0.3.0/source.json": "c55ed591aa5009401ddf80ded9762ac32c358d2517ee7820be981e2de9756cf3",
"https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca",
"https://bcr.bazel.build/modules/rules_swift/1.18.0/MODULE.bazel": "a6aba73625d0dc64c7b4a1e831549b6e375fbddb9d2dde9d80c9de6ec45b24c9",
"https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046",
"https://bcr.bazel.build/modules/rules_swift/2.4.0/MODULE.bazel": "1639617eb1ede28d774d967a738b4a68b0accb40650beadb57c21846beab5efd",
"https://bcr.bazel.build/modules/rules_swift/2.4.0/source.json": "a6577f57f9febbdc015a01f2a8f3487422032f134d6c61d18ed8e8ca3b9acc7c",
"https://bcr.bazel.build/modules/rules_swift/2.3.1/MODULE.bazel": "0b42093600d9226bcbdb31fb86d25d4204293d716fdbb2e50a1852547032a660",
"https://bcr.bazel.build/modules/rules_swift/2.3.1/source.json": "87d28609c37d2061db2f6fc3aae8ab7fbda9adf556cd88fbd0c7d520b8d81391",
"https://bcr.bazel.build/modules/stardoc/0.5.0/MODULE.bazel": "f9f1f46ba8d9c3362648eea571c6f9100680efc44913618811b58cc9c02cd678",
"https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8",
"https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c",
@@ -368,20 +339,14 @@
"https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216",
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91",
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/source.json": "32bd87e5f4d7acc57c5b2ff7c325ae3061d5e242c0c4c214ae87e0f1c13e54cb",
"https://bcr.bazel.build/modules/tar.bzl/0.2.1/MODULE.bazel": "52d1c00a80a8cc67acbd01649e83d8dd6a9dc426a6c0b754a04fe8c219c76468",
"https://bcr.bazel.build/modules/tar.bzl/0.5.1/MODULE.bazel": "7c2eb3dcfc53b0f3d6f9acdfd911ca803eaf92aadf54f8ca6e4c1f3aee288351",
"https://bcr.bazel.build/modules/tar.bzl/0.6.0/MODULE.bazel": "a3584b4edcfafcabd9b0ef9819808f05b372957bbdff41601429d5fd0aac2e7c",
"https://bcr.bazel.build/modules/tar.bzl/0.6.0/source.json": "4a620381df075a16cb3a7ed57bd1d05f7480222394c64a20fa51bdb636fda658",
"https://bcr.bazel.build/modules/toolchains_llvm/1.6.0/MODULE.bazel": "39603859cafb1c6830160fcd6370552e836790e6abb2bfb8d13bff53c0c10a64",
"https://bcr.bazel.build/modules/toolchains_llvm/1.6.0/source.json": "6bd3ef95a288dd2bb1582eca332af850c9a5428a23bb92cb1c57c2dfe6cb7369",
"https://bcr.bazel.build/modules/toolchains_llvm/1.4.0/MODULE.bazel": "05239402b7374293359c2f22806f420b75aa5d6f4b15a2eaa809a2c214d58b31",
"https://bcr.bazel.build/modules/toolchains_llvm/1.4.0/source.json": "229a516d282b17a82be54c6e3ae220a1b750fb55a8495567e5c7a9d09423f3e2",
"https://bcr.bazel.build/modules/upb/0.0.0-20211020-160625a/MODULE.bazel": "6cced416be2dc5b9c05efd5b997049ba795e5e4e6fafbe1624f4587767638928",
"https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43",
"https://bcr.bazel.build/modules/upb/0.0.0-20230516-61a97ef/MODULE.bazel": "c0df5e35ad55e264160417fd0875932ee3c9dda63d9fccace35ac62f45e1b6f9",
"https://bcr.bazel.build/modules/upb/0.0.0-20230907-e7430e6/MODULE.bazel": "3a7dedadf70346e678dc059dbe44d05cbf3ab17f1ce43a1c7a42edc7cbf93fd9",
"https://bcr.bazel.build/modules/xds/0.0.0-20240423-555b57e/MODULE.bazel": "cea509976a77e34131411684ef05a1d6ad194dd71a8d5816643bc5b0af16dc0f",
"https://bcr.bazel.build/modules/xds/0.0.0-20240423-555b57e/source.json": "7227e1fcad55f3f3cab1a08691ecd753cb29cc6380a47bc650851be9f9ad6d20",
"https://bcr.bazel.build/modules/yq.bzl/0.1.1/MODULE.bazel": "9039681f9bcb8958ee2c87ffc74bdafba9f4369096a2b5634b88abc0eaefa072",
"https://bcr.bazel.build/modules/yq.bzl/0.1.1/source.json": "2d2bad780a9f2b9195a4a370314d2c17ae95eaa745cefc2e12fbc49759b15aa3",
"https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0",
"https://bcr.bazel.build/modules/zlib/1.2.12/MODULE.bazel": "3b1a8834ada2a883674be8cbd36ede1b6ec481477ada359cd2d3ddc562340b27",
"https://bcr.bazel.build/modules/zlib/1.2.13/MODULE.bazel": "aa6deb1b83c18ffecd940c4119aff9567cd0a671d7bba756741cb2ef043a29d5",
@@ -396,7 +361,7 @@
"@@apple_support~//crosstool:setup.bzl%apple_cc_configure_extension": {
"general": {
"bzlTransitiveDigest": "Z3yAd66IJL0GAZUTSeMOjoHiE1SZPPwiIs/XQui5BvE=",
"usagesDigest": "tl3VVeQX3Hzh7FhM2gjnkCwEJpRMlY5S6a850WY/xvc=",
"usagesDigest": "TOb4CUri5UsTKxgIDTNzR0ddIc21eYLCRIm+jqQmjlg=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
@@ -423,7 +388,7 @@
},
"@@aspect_rules_esbuild~//esbuild:extensions.bzl%esbuild": {
"general": {
"bzlTransitiveDigest": "8L5Llfl6uxIWXd5GR+Qmmm04/jxp6TuJH5LFhIZIUCA=",
"bzlTransitiveDigest": "8iOqbPY5ve3DvjzaI1mJZ8XTiJypN2PeWvcKOvmZLy8=",
"usagesDigest": "iDVoyPxUeADmfK8ssoyG3Ehq1bj6p7A43LpEiE266os=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
@@ -502,7 +467,6 @@
"extra_build_content": "",
"generate_bzl_library_targets": false,
"extract_full_archive": false,
"exclude_package_contents": [],
"system_tar": "auto"
}
},
@@ -527,17 +491,11 @@
"package_visibility": [
"//visibility:public"
],
"replace_package": "",
"exclude_package_contents": []
"replace_package": ""
}
}
},
"recordedRepoMappingEntries": [
[
"aspect_bazel_lib~",
"bazel_lib",
"bazel_lib~"
],
[
"aspect_bazel_lib~",
"bazel_skylib",
@@ -548,11 +506,6 @@
"bazel_tools",
"bazel_tools"
],
[
"aspect_bazel_lib~",
"tar.bzl",
"tar.bzl~"
],
[
"aspect_rules_esbuild~",
"aspect_rules_js",
@@ -568,11 +521,6 @@
"aspect_bazel_lib",
"aspect_bazel_lib~"
],
[
"aspect_rules_js~",
"aspect_rules_js",
"aspect_rules_js~"
],
[
"aspect_rules_js~",
"bazel_skylib",
@@ -582,31 +530,6 @@
"aspect_rules_js~",
"bazel_tools",
"bazel_tools"
],
[
"bazel_lib~",
"bazel_skylib",
"bazel_skylib~"
],
[
"bazel_lib~",
"bazel_tools",
"bazel_tools"
],
[
"tar.bzl~",
"aspect_bazel_lib",
"aspect_bazel_lib~"
],
[
"tar.bzl~",
"bazel_skylib",
"bazel_skylib~"
],
[
"tar.bzl~",
"tar.bzl",
"tar.bzl~"
]
]
}
@@ -1214,7 +1137,7 @@
"@@rules_nodejs~//nodejs:extensions.bzl%node": {
"general": {
"bzlTransitiveDigest": "q44Ox2Nwogn6OsO0Xw5lhjkd/xmxkvvpwVOn5P4pmHQ=",
"usagesDigest": "Py5Wgc5kr5fTMe1FKrlFK276B6SodesXp6nw2Fq5XA8=",
"usagesDigest": "WQpLKLujnBfrx9sMWCJgyaK9P04binseT6CGBy3vP4E=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
@@ -1342,333 +1265,6 @@
"recordedRepoMappingEntries": []
}
},
"@@rules_oci~//oci:extensions.bzl%oci": {
"general": {
"bzlTransitiveDigest": "AOLP47LtVHSKSDiukosQymx543OwcgeoQP666wwuj3o=",
"usagesDigest": "3Xsv1/UEV8MOARW4BZScPw3Gxtx19OQ5EXOqcL1p9bI=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"eclipse_temurin_17_linux_amd64": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_pull",
"attributes": {
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/eclipse-temurin",
"identifier": "17-jdk",
"platform": "linux/amd64",
"target_name": "eclipse_temurin_17_linux_amd64",
"bazel_tags": []
}
},
"eclipse_temurin_17": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_alias",
"attributes": {
"target_name": "eclipse_temurin_17",
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/eclipse-temurin",
"identifier": "17-jdk",
"platforms": {
"@@platforms//cpu:x86_64": "@eclipse_temurin_17_linux_amd64"
},
"bzlmod_repository": "eclipse_temurin_17",
"reproducible": true
}
},
"ubuntu_24_04_linux_amd64": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_pull",
"attributes": {
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/ubuntu",
"identifier": "24.04",
"platform": "linux/amd64",
"target_name": "ubuntu_24_04_linux_amd64",
"bazel_tags": []
}
},
"ubuntu_24_04_linux_arm64_v8": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_pull",
"attributes": {
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/ubuntu",
"identifier": "24.04",
"platform": "linux/arm64/v8",
"target_name": "ubuntu_24_04_linux_arm64_v8",
"bazel_tags": []
}
},
"ubuntu_24_04": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_alias",
"attributes": {
"target_name": "ubuntu_24_04",
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/ubuntu",
"identifier": "24.04",
"platforms": {
"@@platforms//cpu:x86_64": "@ubuntu_24_04_linux_amd64",
"@@platforms//cpu:arm64": "@ubuntu_24_04_linux_arm64_v8"
},
"bzlmod_repository": "ubuntu_24_04",
"reproducible": true
}
},
"alpine_linux_linux_amd64": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_pull",
"attributes": {
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/alpine",
"identifier": "3.21",
"platform": "linux/amd64",
"target_name": "alpine_linux_linux_amd64",
"bazel_tags": []
}
},
"alpine_linux": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_alias",
"attributes": {
"target_name": "alpine_linux",
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/alpine",
"identifier": "3.21",
"platforms": {
"@@platforms//cpu:x86_64": "@alpine_linux_linux_amd64"
},
"bzlmod_repository": "alpine_linux",
"reproducible": true
}
},
"oci_crane_darwin_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "darwin_amd64",
"crane_version": "v0.18.0"
}
},
"oci_crane_darwin_arm64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "darwin_arm64",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_arm64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_arm64",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_armv6": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_armv6",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_i386": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_i386",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_s390x": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_s390x",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_amd64",
"crane_version": "v0.18.0"
}
},
"oci_crane_windows_armv6": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "windows_armv6",
"crane_version": "v0.18.0"
}
},
"oci_crane_windows_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "windows_amd64",
"crane_version": "v0.18.0"
}
},
"oci_crane_toolchains": {
"bzlFile": "@@rules_oci~//oci/private:toolchains_repo.bzl",
"ruleClassName": "toolchains_repo",
"attributes": {
"toolchain_type": "@rules_oci//oci:crane_toolchain_type",
"toolchain": "@oci_crane_{platform}//:crane_toolchain"
}
},
"oci_regctl_darwin_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "darwin_amd64"
}
},
"oci_regctl_darwin_arm64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "darwin_arm64"
}
},
"oci_regctl_linux_arm64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "linux_arm64"
}
},
"oci_regctl_linux_s390x": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "linux_s390x"
}
},
"oci_regctl_linux_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "linux_amd64"
}
},
"oci_regctl_windows_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "windows_amd64"
}
},
"oci_regctl_toolchains": {
"bzlFile": "@@rules_oci~//oci/private:toolchains_repo.bzl",
"ruleClassName": "toolchains_repo",
"attributes": {
"toolchain_type": "@rules_oci//oci:regctl_toolchain_type",
"toolchain": "@oci_regctl_{platform}//:regctl_toolchain"
}
}
},
"moduleExtensionMetadata": {
"explicitRootModuleDirectDeps": [
"eclipse_temurin_17",
"eclipse_temurin_17_linux_amd64",
"ubuntu_24_04",
"ubuntu_24_04_linux_amd64",
"ubuntu_24_04_linux_arm64_v8",
"alpine_linux",
"alpine_linux_linux_amd64"
],
"explicitRootModuleDirectDevDeps": [],
"useAllRepos": "NO",
"reproducible": false
},
"recordedRepoMappingEntries": [
[
"aspect_bazel_lib~",
"bazel_tools",
"bazel_tools"
],
[
"bazel_features~",
"bazel_tools",
"bazel_tools"
],
[
"rules_oci~",
"aspect_bazel_lib",
"aspect_bazel_lib~"
],
[
"rules_oci~",
"bazel_features",
"bazel_features~"
],
[
"rules_oci~",
"bazel_skylib",
"bazel_skylib~"
]
]
}
},
"@@rules_python~//python/uv:uv.bzl%uv": {
"general": {
"bzlTransitiveDigest": "Xpqjnjzy6zZ90Es9Wa888ZLHhn7IsNGbph/e6qoxzw8=",
"usagesDigest": "qI5PVlIum/YAnGJg5oXGHzDkMFWt2aNSUZY4G8PBbic=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"uv": {
"bzlFile": "@@rules_python~//python/uv/private:uv_toolchains_repo.bzl",
"ruleClassName": "uv_toolchains_repo",
"attributes": {
"toolchain_type": "'@@rules_python~//python/uv:uv_toolchain_type'",
"toolchain_names": [
"none"
],
"toolchain_implementations": {
"none": "'@@rules_python~//python:none'"
},
"toolchain_compatible_with": {
"none": [
"@platforms//:incompatible"
]
},
"toolchain_target_settings": {}
}
}
},
"recordedRepoMappingEntries": [
[
"rules_python~",
"platforms",
"platforms"
]
]
}
},
"@@rules_scala~//scala/extensions:config.bzl%scala_config": {
"general": {
"bzlTransitiveDigest": "TdBxhkZTM7VU6teIFS+KoonKU7wmb5BL7leCWWx7yX8=",
@@ -1697,7 +1293,7 @@
},
"@@rules_scala~//scala/extensions:deps.bzl%scala_deps": {
"general": {
"bzlTransitiveDigest": "5SDZrXQHW6tI/VEw+La2OPOK4ZWm0LGTxnChXOXBCag=",
"bzlTransitiveDigest": "F2PMm61fmZ/IE+VSw1rigJ71hBDD7k3vqyYR1/GgXeA=",
"usagesDigest": "kwo8oolISmSSITnit4b4S0vBiUtHlHK0WLDUwScxmOg=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
@@ -5145,7 +4741,7 @@
"@@rules_swift~//swift:extensions.bzl%non_module_deps": {
"general": {
"bzlTransitiveDigest": "PAIMhc1bVKfcyoHeg0xO8LMS9KN5yzbsMGwa5O2ifJM=",
"usagesDigest": "l2vIL7SL4tJqHIVLWd78Y/ym+r0II64lmvoX8o+0Bb0=",
"usagesDigest": "A3fzk5iHsrLdI3PokT1bHIdeJ2j9tc09H3/3Old6IfU=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
@@ -5308,6 +4904,85 @@
]
]
}
},
"@@toolchains_llvm~//toolchain/extensions:llvm.bzl%llvm": {
"general": {
"bzlTransitiveDigest": "afRF0aFOIUrkYl3o040WQ606ep1qciEXzjnAxT3Kek8=",
"usagesDigest": "sYVuhiCAQehFTnGTv0bNtTBR4WorebpWBNxF0mRusyw=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"llvm_toolchain_llvm": {
"bzlFile": "@@toolchains_llvm~//toolchain:rules.bzl",
"ruleClassName": "llvm",
"attributes": {
"alternative_llvm_sources": [],
"auth_patterns": {},
"distribution": "auto",
"exec_arch": "",
"exec_os": "",
"libclang_rt": {},
"llvm_mirror": "",
"llvm_version": "20.1.2",
"llvm_versions": {},
"netrc": "",
"sha256": {},
"strip_prefix": {},
"urls": {}
}
},
"llvm_toolchain": {
"bzlFile": "@@toolchains_llvm~//toolchain:rules.bzl",
"ruleClassName": "toolchain",
"attributes": {
"absolute_paths": false,
"archive_flags": {},
"compile_flags": {},
"conly_flags": {},
"coverage_compile_flags": {},
"coverage_link_flags": {},
"cxx_builtin_include_directories": {},
"cxx_flags": {},
"cxx_standard": {},
"dbg_compile_flags": {},
"exec_arch": "",
"exec_os": "",
"extra_exec_compatible_with": {},
"extra_target_compatible_with": {},
"link_flags": {},
"link_libs": {},
"llvm_versions": {
"": "20.1.2"
},
"opt_compile_flags": {},
"opt_link_flags": {},
"stdlib": {},
"target_settings": {},
"unfiltered_compile_flags": {},
"toolchain_roots": {},
"sysroot": {}
}
}
},
"recordedRepoMappingEntries": [
[
"toolchains_llvm~",
"bazel_skylib",
"bazel_skylib~"
],
[
"toolchains_llvm~",
"bazel_tools",
"bazel_tools"
],
[
"toolchains_llvm~",
"toolchains_llvm",
"toolchains_llvm~"
]
]
}
}
}
}
-347
View File
@@ -1,347 +0,0 @@
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load", "oci_push")
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
#
# Shared utilities layer (busybox for nc, wget, etc.)
#
pkg_tar(
name = "busybox_layer",
srcs = ["@busybox_x86_64//file"],
package_dir = "/usr/local/bin",
remap_paths = {
"file/busybox": "busybox",
},
symlinks = {
"/usr/local/bin/nc": "busybox",
},
)
pkg_tar(
name = "busybox_layer_arm64",
srcs = ["@busybox_aarch64//file"],
package_dir = "/usr/local/bin",
remap_paths = {
"file/busybox": "busybox",
},
symlinks = {
"/usr/local/bin/nc": "busybox",
},
)
#
# Eagle Server Docker Image
#
# Build: bazel build //ci:eagle_server_image
# Load: bazel run //ci:eagle_server_load
# Push: bazel run //ci:eagle_server_push
#
# Package the deploy JAR
pkg_tar(
name = "eagle_server_jar_layer",
srcs = ["//src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar"],
package_dir = "/app",
)
# Package the game resources needed at runtime
pkg_tar(
name = "eagle_resources_layer",
srcs = [
"//src/main/resources/net/eagle0/eagle:beasts",
"//src/main/resources/net/eagle0/eagle:game_parameters",
"//src/main/resources/net/eagle0/eagle:headshots",
"//src/main/resources/net/eagle0/eagle:heroes",
"//src/main/resources/net/eagle0/eagle:province_map",
"//src/main/resources/net/eagle0/eagle:settings",
],
package_dir = "/app/resources",
)
oci_image(
name = "eagle_server_image",
base = "@eclipse_temurin_17_linux_amd64",
entrypoint = [
"java",
"-Xmx2g",
"-XX:+UseG1GC",
# JFR profiling support
"-XX:+UnlockDiagnosticVMOptions",
"-XX:+DebugNonSafepoints", # Required for JFR to see through inlined methods
"-XX:FlightRecorderOptions=stackdepth=256",
"-jar",
"/app/eagle_server_deploy.jar",
],
env = {
"JAVA_OPTS": "-Xmx2g -XX:+UseG1GC",
},
exposed_ports = ["40032/tcp"],
tars = [
":busybox_layer",
":eagle_server_jar_layer",
":eagle_resources_layer",
],
workdir = "/app",
)
# Load into Docker locally: bazel run //ci:eagle_server_load
oci_load(
name = "eagle_server_load",
image = ":eagle_server_image",
repo_tags = ["eagle0/eagle-server:latest"],
)
# Push to DigitalOcean Container Registry
# Note: No remote_tags here - DigitalOcean converts OCI to Docker format,
# changing the digest and breaking oci_push's tag-by-digest logic.
# Tagging is handled in the CI workflow using crane copy/tag.
oci_push(
name = "eagle_server_push",
image = ":eagle_server_image",
repository = "registry.digitalocean.com/eagle0/eagle-server",
)
#
# Shardok Server Docker Image
#
# Build: bazel build //ci:shardok_server_image
# Load: bazel run //ci:shardok_server_load
# Push: bazel run //ci:shardok_server_push
#
# Package the Shardok binary
pkg_tar(
name = "shardok_binary_layer",
srcs = ["//src/main/cpp/net/eagle0/shardok:shardok-server"],
package_dir = "/app",
)
# Package the Shardok resources (battalion types, settings)
pkg_tar(
name = "shardok_resources_layer",
srcs = [
"//src/main/resources/net/eagle0/shardok:battalion_types",
"//src/main/resources/net/eagle0/shardok:settings",
],
package_dir = "/app/resources",
)
# Package the converted maps
pkg_tar(
name = "shardok_maps_layer",
srcs = ["//src/main/resources/net/eagle0/shardok/maps"],
package_dir = "/app/resources/maps",
)
oci_image(
name = "shardok_server_image",
base = "@ubuntu_24_04_linux_amd64",
entrypoint = ["/app/shardok-server"],
exposed_ports = [
"40042/tcp",
"40052/tcp",
],
tars = [
":busybox_layer",
":shardok_binary_layer",
":shardok_resources_layer",
":shardok_maps_layer",
],
workdir = "/app",
)
# Load into Docker locally: bazel run //ci:shardok_server_load
oci_load(
name = "shardok_server_load",
image = ":shardok_server_image",
repo_tags = ["eagle0/shardok-server:latest"],
)
# Push to DigitalOcean Container Registry
# Note: No remote_tags here - DigitalOcean converts OCI to Docker format,
# changing the digest and breaking oci_push's tag-by-digest logic.
# Tagging is handled in the CI workflow using crane copy/tag.
oci_push(
name = "shardok_server_push",
image = ":shardok_server_image",
repository = "registry.digitalocean.com/eagle0/shardok-server",
)
#
# Shardok Server ARM64 Docker Image (for Hetzner on-demand compute)
#
# Build: bazel build //ci:shardok_server_image_arm64 --platforms=//:linux_arm64 --extra_toolchains=@llvm_toolchain_linux_arm64//:all
# Load: bazel run //ci:shardok_server_load_arm64
# Push: bazel run //ci:shardok_server_push_arm64
#
# Package the Shardok binary (ARM64 version - must be built with --platforms=//:linux_arm64)
pkg_tar(
name = "shardok_binary_layer_arm64",
srcs = ["//src/main/cpp/net/eagle0/shardok:shardok-server"],
package_dir = "/app",
)
oci_image(
name = "shardok_server_image_arm64",
base = "@ubuntu_24_04_linux_arm64_v8",
entrypoint = ["/app/shardok-server"],
exposed_ports = [
"40042/tcp",
"40052/tcp",
],
tars = [
# Note: busybox_layer_arm64 omitted - busybox.net has SSL issues
# Health checks can use the shardok-server binary itself or be added later
":shardok_binary_layer_arm64",
":shardok_resources_layer",
":shardok_maps_layer",
],
workdir = "/app",
)
# Load into Docker locally (ARM64): bazel run //ci:shardok_server_load_arm64
oci_load(
name = "shardok_server_load_arm64",
image = ":shardok_server_image_arm64",
repo_tags = ["eagle0/shardok-server:latest-arm64"],
)
# Push to DigitalOcean Container Registry (for Hetzner deployment)
# Uses same repository as x86 but with arm64- tag prefix
oci_push(
name = "shardok_server_push_arm64",
image = ":shardok_server_image_arm64",
repository = "registry.digitalocean.com/eagle0/shardok-server",
)
#
# Admin Server Docker Image (Go)
#
# Build: bazel build //ci:admin_server_image
# Load: bazel run //ci:admin_server_load
# Push: bazel run //ci:admin_server_push
#
# Package the Go admin binary (explicit Linux x86_64 target)
pkg_tar(
name = "admin_binary_layer",
srcs = ["//src/main/go/net/eagle0/admin_server:admin_server_linux_amd64"],
package_dir = "/app",
)
oci_image(
name = "admin_server_image",
base = "@alpine_linux_linux_amd64",
entrypoint = ["/app/admin_server_linux_amd64"],
exposed_ports = ["8080/tcp"],
tars = [
":busybox_layer",
":admin_binary_layer",
],
workdir = "/app",
)
# Load into Docker locally: bazel run //ci:admin_server_load
oci_load(
name = "admin_server_load",
image = ":admin_server_image",
repo_tags = ["eagle0/admin-server:latest"],
)
# Push to DigitalOcean Container Registry
oci_push(
name = "admin_server_push",
image = ":admin_server_image",
repository = "registry.digitalocean.com/eagle0/admin-server",
)
#
# JFR Sidecar Docker Image (Go + JDK for jcmd)
#
# This sidecar runs with shared PID namespace to access the Eagle JVM.
# Build: bazel build //ci:jfr_sidecar_image
# Load: bazel run //ci:jfr_sidecar_load
# Push: bazel run //ci:jfr_sidecar_push
#
# Package the Go JFR server binary
pkg_tar(
name = "jfr_sidecar_binary_layer",
srcs = ["//src/main/go/net/eagle0/jfr_server:jfr_server_linux_amd64"],
package_dir = "/app",
)
oci_image(
name = "jfr_sidecar_image",
# Use JDK base image - we need jcmd to dump JFR recordings
base = "@eclipse_temurin_17_linux_amd64",
entrypoint = ["/app/jfr_server_linux_amd64"],
exposed_ports = ["8081/tcp"],
tars = [
":jfr_sidecar_binary_layer",
],
workdir = "/app",
)
# Load into Docker locally: bazel run //ci:jfr_sidecar_load
oci_load(
name = "jfr_sidecar_load",
image = ":jfr_sidecar_image",
repo_tags = ["eagle0/jfr-sidecar:latest"],
)
# Push to DigitalOcean Container Registry
oci_push(
name = "jfr_sidecar_push",
image = ":jfr_sidecar_image",
repository = "registry.digitalocean.com/eagle0/jfr-sidecar",
)
#
# Auth Server Docker Image (Go)
#
# This is the external OAuth service that handles OAuth flows and JWT creation.
# Build: bazel build //ci:auth_server_image
# Load: bazel run //ci:auth_server_load
# Push: bazel run //ci:auth_server_push
#
# Package the Go auth binary (explicit Linux x86_64 target)
pkg_tar(
name = "auth_binary_layer",
srcs = [
"//src/main/go/net/eagle0/authcli:authcli_linux_amd64",
"//src/main/go/net/eagle0/authservice:authservice_linux_amd64",
],
package_dir = "/app",
)
oci_image(
name = "auth_server_image",
base = "@alpine_linux_linux_amd64",
entrypoint = ["/app/authservice_linux_amd64"],
exposed_ports = [
"40033/tcp", # gRPC
"8080/tcp", # HTTP OAuth callback
],
tars = [
":busybox_layer",
":auth_binary_layer",
],
workdir = "/app",
)
# Load into Docker locally: bazel run //ci:auth_server_load
oci_load(
name = "auth_server_load",
image = ":auth_server_image",
repo_tags = ["eagle0/auth-server:latest"],
)
# Push to DigitalOcean Container Registry
oci_push(
name = "auth_server_push",
image = ":auth_server_image",
repository = "registry.digitalocean.com/eagle0/auth-server",
)
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env bash
set -euxo pipefail
. ./ci/unity_version.sh
WORKSPACE=$(pwd)
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
BUILD_DIR=$1
LOG_PATH=$2
echo "Building Mac in $BUILD_DIR"
echo "Cleaning up $BUILD_DIR"
/bin/rm -rf "$BUILD_DIR"
/bin/mkdir -p "$BUILD_DIR"
${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
-nographics \
-batchmode \
-quit \
-buildOSXUniversalPlayer "$BUILD_DIR/eagle0.app" \
-logFile "$LOG_PATH" \
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
-19
View File
@@ -1,19 +0,0 @@
#!/bin/bash
set -euxo pipefail
COMMIT=$(/usr/bin/git rev-parse --short HEAD)
/bin/echo "build protos"
./scripts/build_protos.sh
/bin/echo "build Mac plugin"
./scripts/build_mac_plugin.sh
git log -3
/bin/echo "build Mac"
LOG_PATH="/tmp/eagle0/editor_mac.log"
BUILD_DIR=$1
./ci/github_actions/build_mac.sh "$BUILD_DIR" "$LOG_PATH"
+2 -16
View File
@@ -1,20 +1,6 @@
#!/bin/bash
set -uxo pipefail
set -euxo pipefail
/bin/echo "persist Library/"
# rsync may exit with code 23 ("partial transfer due to error") if Unity's
# temporary files vanish during the copy. This is acceptable for a cache.
/usr/bin/rsync -rtlDvq src/main/csharp/net/eagle0/clients/unity/eagle0/Library/ /tmp/eagle0/Library/
rsync_exit=$?
if [ $rsync_exit -eq 0 ]; then
exit 0
elif [ $rsync_exit -eq 23 ]; then
echo "Warning: rsync exited with 23 (some files vanished during copy). This is expected for Unity temp files."
exit 0
else
echo "Error: rsync failed with exit code $rsync_exit"
exit $rsync_exit
fi
/usr/bin/rsync -rtlDvq src/main/csharp/net/eagle0/clients/unity/eagle0/Library/ /tmp/eagle0/Library/
-18
View File
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Allow JIT compilation (required for Mono/IL2CPP) -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<!-- Allow unsigned executable memory (required for Unity) -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Disable library validation (required for plugins) -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Allow outgoing network connections -->
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
+2 -1
View File
@@ -1 +1,2 @@
UNITY_VERSION='6000.3.0f1'
UNITY_VERSION='6000.2.7f2'
-40
View File
@@ -1,40 +0,0 @@
# Environment template for production deployment
# This file defines all env vars used by docker-compose.prod.yml
# Workflows should update their specific vars without overwriting others
# Container images (managed by respective build workflows)
EAGLE_IMAGE=registry.digitalocean.com/eagle0/eagle-server:latest
SHARDOK_IMAGE=registry.digitalocean.com/eagle0/shardok-server:latest
ADMIN_IMAGE=registry.digitalocean.com/eagle0/admin-server:latest
JFR_SIDECAR_IMAGE=registry.digitalocean.com/eagle0/jfr-sidecar:latest
AUTH_IMAGE=registry.digitalocean.com/eagle0/auth-server:latest
# OpenAI / LLM
OPENAI_API_KEY=
GPT_MODEL_NAME=gpt-4o
# DigitalOcean Spaces (S3-compatible storage)
EAGLE_ENABLE_S3=false
DO_SPACES_ACCESS_KEY=
DO_SPACES_SECRET_KEY=
# JWT authentication
JWT_PRIVATE_KEY=
# OAuth providers
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Shardok connection
SHARDOK_ADDRESS=shardok:40042
SHARDOK_AUTH_TOKEN=
# Monitoring
SENTRY_DSN=
# Email (Fastmail JMAP)
FASTMAIL_API_TOKEN=
FASTMAIL_FROM_EMAIL=
FASTMAIL_FROM_NAME=
-59
View File
@@ -1,59 +0,0 @@
#!/bin/bash
# Update .env file without losing other variables
# Usage: ./update-env.sh KEY1=value1 KEY2=value2 ...
#
# This script:
# 1. Creates .env from template if it doesn't exist
# 2. Updates only the specified KEY=value pairs
# 3. Preserves all other existing values
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ENV_FILE="${ENV_FILE:-/opt/eagle0/.env}"
TEMPLATE_FILE="${TEMPLATE_FILE:-$SCRIPT_DIR/env.template}"
# Create .env from template if it doesn't exist
if [ ! -f "$ENV_FILE" ]; then
if [ -f "$TEMPLATE_FILE" ]; then
echo "Creating .env from template..."
grep -v '^#' "$TEMPLATE_FILE" | grep -v '^$' > "$ENV_FILE"
else
echo "Creating empty .env..."
touch "$ENV_FILE"
fi
chmod 600 "$ENV_FILE"
fi
# Process each KEY=VALUE argument
for arg in "$@"; do
# Skip empty args
[ -z "$arg" ] && continue
# Parse KEY=VALUE
KEY="${arg%%=*}"
VALUE="${arg#*=}"
# Skip if no key
[ -z "$KEY" ] && continue
# Skip setting empty values (keeps existing value)
if [ -z "$VALUE" ]; then
echo "Skipping $KEY (empty value)"
continue
fi
# Remove existing line for this key and add new one
if grep -q "^${KEY}=" "$ENV_FILE" 2>/dev/null; then
# Key exists, update it
sed -i "s|^${KEY}=.*|${KEY}=${VALUE}|" "$ENV_FILE"
echo "Updated $KEY"
else
# Key doesn't exist, add it
echo "${KEY}=${VALUE}" >> "$ENV_FILE"
echo "Added $KEY"
fi
done
chmod 600 "$ENV_FILE"
echo "Done updating $ENV_FILE"
-310
View File
@@ -1,310 +0,0 @@
# Docker Compose for production deployment
#
# Local testing:
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:shardok_server_load && bazel run //ci:auth_server_load
# Run: docker compose -f docker-compose.prod.yml up
#
# Production deployment:
# Run: docker compose -f docker-compose.prod.yml up -d
services:
# Blue-green deployment: eagle-blue is the primary (production) instance
# eagle-green is the staging instance for zero-downtime deployments
# See scripts/deploy-blue-green.sh for deployment workflow
eagle-blue:
image: ${EAGLE_IMAGE:-registry.digitalocean.com/eagle0/eagle-server:latest}
container_name: eagle-blue
command:
- "--gpt-model-name"
- "${GPT_MODEL_NAME:-gpt-5.1}"
- "--shardok-interface-remote-address"
- "${SHARDOK_ADDRESS:-shardok:40042}"
- "--auth-service-url"
- "auth:40033"
ports:
- "40032:40032"
environment:
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
# Auth token for remote Shardok on Hetzner (only used when shardok address contains .eagle0.net)
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
# Use persistent volume for save data (users, games, etc.)
EAGLE_SAVE_DIR: "/app/saves"
EAGLE_ARCHIVE_DIR: "/app/archived"
SENTRY_DSN: "${SENTRY_DSN:-}"
SENTRY_ENVIRONMENT: "production"
volumes:
- ./saves:/app/saves # Game saves and user database
- ./archived:/app/archived # Archived completed games
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-blue jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
- jvm-tmp:/tmp # Shared with jfr-sidecar for JVM attach socket files
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
depends_on:
- shardok
- auth
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 40032 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
eagle-green:
image: ${EAGLE_IMAGE_NEW:-registry.digitalocean.com/eagle0/eagle-server:latest}
container_name: eagle-green
profiles: ["blue-green"] # Only started during blue-green deployment
command:
- "--gpt-model-name"
- "${GPT_MODEL_NAME:-gpt-5.1}"
- "--shardok-interface-remote-address"
- "${SHARDOK_ADDRESS:-shardok:40042}"
- "--auth-service-url"
- "auth:40033"
ports:
- "40034:40032" # Different host port for staging
environment:
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
EAGLE_SAVE_DIR: "/app/saves"
EAGLE_ARCHIVE_DIR: "/app/archived"
SENTRY_DSN: "${SENTRY_DSN:-}"
SENTRY_ENVIRONMENT: "production"
volumes:
- ./saves:/app/saves # Same save directory as blue
- ./archived:/app/archived # Same archive directory as blue
- ./jfr:/app/jfr # JFR recordings (same as blue)
- jvm-tmp:/tmp # Shared with jfr-sidecar-green for JVM attach socket files
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
depends_on:
- shardok
- auth
restart: "no" # Don't auto-restart during deployment
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 40032 || exit 1"]
interval: 10s
timeout: 5s
retries: 6
start_period: 60s
# Backward compatibility alias - for scripts that reference 'eagle' service
eagle:
extends:
service: eagle-blue
auth:
image: ${AUTH_IMAGE:-registry.digitalocean.com/eagle0/auth-server:latest}
container_name: auth-server
environment:
# gRPC port for Auth service
AUTH_GRPC_PORT: "40033"
# HTTP port for OAuth callbacks
AUTH_HTTP_PORT: "8080"
# User data persistence directory
AUTH_DATA_DIR: "/app/data"
# Legacy path for migrating users from Eagle (Phase 1 migration)
AUTH_LEGACY_DATA_DIR: "/app/saves/auth"
# OAuth provider credentials
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID:-}"
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}"
# Server base URL for OAuth callbacks
SERVER_BASE_URL: "${SERVER_BASE_URL:-https://prod.eagle0.net}"
# JWT keys - PEM files in volume, bootstrapped from JWK on first run
JWT_KEYS_PATH: "/etc/eagle0/keys"
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
# Fastmail JMAP API for sending invitation emails
FASTMAIL_API_TOKEN: "${FASTMAIL_API_TOKEN:-}"
FASTMAIL_FROM_EMAIL: "${FASTMAIL_FROM_EMAIL:-}"
FASTMAIL_FROM_NAME: "${FASTMAIL_FROM_NAME:-}"
# Require invitation codes for new user registration
REQUIRE_INVITATION_CODE: "true"
# Note: port 40033 is exposed via nginx, not directly
volumes:
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with Eagle
- ./auth-data:/app/data # User database persistence
- ./saves:/app/saves:ro # Read-only access to Eagle's saves for migration
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 40033 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
shardok:
image: ${SHARDOK_IMAGE:-registry.digitalocean.com/eagle0/shardok-server:latest}
container_name: shardok-server
mem_limit: 1g
memswap_limit: 1g # Prevent swap, OOM-kill cleanly instead
ports:
- "40042:40042"
- "40052:40052"
environment:
SHARDOK_RESOURCES_PATH: "/app/resources"
SHARDOK_MAPS_PATH: "/app/resources/maps"
SHARDOK_EAGLE_INTERFACE_ADDRESS: "0.0.0.0:40042"
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 40042 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
nginx:
image: nginx:alpine
container_name: nginx
ports:
- "443:443"
- "80:80"
- "40033:40033" # Go Auth service gRPC (Phase 2 direct client connections)
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./certbot/conf:/etc/letsencrypt:ro
- ./certbot/www:/var/www/certbot:ro
- ./auth:/etc/nginx/auth:ro
depends_on:
- admin
# Note: nginx connects to eagle via EAGLE_ADDR (default: eagle-blue:40032)
# For blue-green deployments, update EAGLE_ADDR in .env before switching
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
admin:
image: ${ADMIN_IMAGE:-registry.digitalocean.com/eagle0/admin-server:latest}
container_name: admin-server
command:
- "--eagle-addr"
- "${EAGLE_ADDR:-eagle-blue:40032}" # Can be switched for blue-green
- "--auth-addr"
- "auth:40033"
- "--jfr-sidecar-addr"
- "${JFR_SIDECAR_ADDR:-jfr-sidecar:8081}" # Can be switched for blue-green
- "--http-port"
- "8080"
# No external port - accessed via nginx at admin.eagle0.net
depends_on:
- auth
# Note: admin connects to eagle via EAGLE_ADDR and jfr-sidecar via JFR_SIDECAR_ADDR
# For blue-green deployments, set both in .env before switching
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:8080/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
jfr-sidecar:
image: ${JFR_SIDECAR_IMAGE:-registry.digitalocean.com/eagle0/jfr-sidecar:latest}
container_name: jfr-sidecar
# Share PID namespace with Eagle to access its JVM via jcmd
# For blue-green: use JFR_SIDECAR_ADDR=jfr-sidecar-green:8081 when green is active
pid: "service:eagle-blue"
volumes:
- jvm-tmp:/tmp # Shared with Eagle for JVM attach socket files
depends_on:
- eagle-blue
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "2"
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:8081/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
jfr-sidecar-green:
image: ${JFR_SIDECAR_IMAGE:-registry.digitalocean.com/eagle0/jfr-sidecar:latest}
container_name: jfr-sidecar-green
profiles: ["blue-green"] # Only started during blue-green deployment
# Share PID namespace with Eagle green instance
pid: "service:eagle-green"
volumes:
- jvm-tmp:/tmp # Shared with Eagle for JVM attach socket files
depends_on:
- eagle-green
restart: "no" # Don't auto-restart during deployment
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "2"
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:8081/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
certbot:
image: certbot/certbot
container_name: certbot
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
volumes:
jvm-tmp:
# Shared /tmp for JVM attach socket files between Eagle and jfr-sidecar
jwt-keys:
# Shared JWT RSA keys between Eagle and auth service
networks:
default:
driver: bridge
enable_ipv6: true
ipam:
config:
- subnet: 172.28.0.0/16
- subnet: fd00:dead:beef::/48
-471
View File
@@ -1,471 +0,0 @@
# Admin Server Enhancement Plan
## Overview
This document outlines enhancements to the Go admin server (`src/main/go/net/eagle0/admin_server/`) to provide a proper web UI for game administration.
### Current State
The admin server provides a full web UI with htmx interactivity:
- `GET /` - Redirect to games list
- `GET /games` - Game list page (HTML)
- `GET /games/{id}` - Game detail with action history
- `GET /games/{id}/history` - History rows (htmx partial, infinite scroll)
- `GET /games/{id}/action/{index}` - Action detail (htmx partial)
- `POST /games/{id}/rewind` - Rewind game to target action
- `GET /settings` - Settings list with live search
- `POST /settings/update` - Update setting value
- `GET /health` - Health check (JSON)
- `GET /api/games` - JSON API for programmatic access
- `GET /api/games/{id}/history` - JSON API for history
### Goals
1. **Web UI**: Replace raw JSON with an interactive HTML interface
2. **Settings Management**: View and modify the 275+ game settings at runtime
3. **Game Rewind**: Restore a game to a previous action count
---
## Architecture
### Technology Choice: Go Templates + htmx
**Rationale:**
- Single binary deployment (no separate frontend build)
- htmx provides interactivity without JavaScript framework complexity
- Familiar HTML/CSS, minimal learning curve
- Excellent for admin tools where SEO and bundle size don't matter
**Alternatives Considered:**
- React/Vue SPA: Adds build complexity, separate deployment artifact
- Server-side only: Less interactive, full page reloads
### Directory Structure
```
src/main/go/net/eagle0/admin_server/
├── admin_server.go # Main entry point, HTTP routes
├── handlers/
│ ├── games.go # Game list and detail handlers
│ ├── settings.go # Settings list and update handlers
│ └── rewind.go # Game rewind handlers
├── templates/
│ ├── layout.html # Base layout with nav, htmx includes
│ ├── games/
│ │ ├── list.html # Game list page
│ │ ├── detail.html # Single game view with history
│ │ └── history.html # Partial for history table (htmx)
│ ├── settings/
│ │ ├── list.html # Settings list with search/filter
│ │ └── edit.html # Inline edit partial (htmx)
│ └── rewind/
│ └── confirm.html # Rewind confirmation modal
├── static/
│ ├── style.css # Minimal CSS (Pico CSS or similar)
│ └── htmx.min.js # htmx library
└── BUILD.bazel
```
---
## Feature 1: Web UI
### Routes
| Route | Method | Description |
|-------|--------|-------------|
| `/` | GET | Redirect to `/games` |
| `/games` | GET | Game list page (HTML) |
| `/games/{id}` | GET | Game detail page with history |
| `/games/{id}/history` | GET | History partial (htmx, for infinite scroll) |
| `/api/games` | GET | JSON API (existing, keep for programmatic access) |
| `/api/games/{id}/history` | GET | JSON API (existing) |
### Game List Page
```
┌─────────────────────────────────────────────────────────────┐
│ Eagle Admin [Settings] [Health] │
├─────────────────────────────────────────────────────────────┤
│ │
│ Running Games (3) │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Game abc123f Round 45 │ │
│ │ Players: Liu Bei (Human), Cao Cao (AI), Sun Quan │ │
│ │ Actions: 1,234 [View] [Rewind]│ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Game def456a Round 12 │ │
│ │ Players: Test Player (Human) │ │
│ │ Actions: 456 [View] [Rewind]│ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Game Detail Page
Shows game info and scrollable action history:
- **Reverse chronological order**: Most recent actions displayed first
- Each action shows: index, type, round ID
- **Clickable actions**: Clicking an action row expands to show JSON representation of the full action data
- "Rewind to here" button on each action row
- Infinite scroll loads more history via htmx (loading older actions as user scrolls down)
### Implementation Notes
1. **Embed static files**: Use `//go:embed` to bundle templates and static files
2. **Template functions**: Add helpers for formatting (hex IDs, timestamps, action summaries)
3. **CSS framework**: Use Pico CSS (~10KB) for clean defaults without classes
---
## Feature 2: Settings Management
### New gRPC Endpoints (Eagle Server)
Add to `eagle.proto`:
```protobuf
message Setting {
string name = 1;
string type = 2; // "Int" or "Double"
string value = 3; // Current value as string
string default_value = 4; // Default from BUILD.bazel
string description = 5; // Optional, for UI hints
}
message GetSettingsRequest {
string filter = 1; // Optional name filter (substring match)
}
message GetSettingsResponse {
repeated Setting settings = 1;
}
message UpdateSettingRequest {
string name = 1;
string value = 2;
}
message UpdateSettingResponse {
Setting setting = 1; // Updated setting
string error = 2; // Empty on success
}
service Eagle {
// ... existing methods ...
rpc GetSettings(GetSettingsRequest) returns (GetSettingsResponse);
rpc UpdateSetting(UpdateSettingRequest) returns (UpdateSettingResponse);
}
```
### Eagle Server Implementation
Create a settings registry that:
1. Discovers all `IntSetting` and `DoubleSetting` instances via reflection or explicit registration
2. Provides get/set by name
3. Validates types on update
```scala
// src/main/scala/net/eagle0/eagle/library/settings/SettingsRegistry.scala
object SettingsRegistry {
private val settings: Map[String, Either[IntSetting, DoubleSetting]] = Map(
"ActionVigorCost" -> Left(ActionVigorCost),
"BaseFoodBuyPrice" -> Right(BaseFoodBuyPrice),
// ... register all 275 settings
)
def getAll(filter: Option[String]): Seq[Setting] = ...
def get(name: String): Option[Setting] = ...
def update(name: String, value: String): Either[String, Setting] = ...
}
```
**Alternative: Code generation**
Rather than manually registering 275 settings, modify `setting_rule.bzl` to generate a registry file during build.
### Admin Server Routes
| Route | Method | Description |
|-------|--------|-------------|
| `/settings` | GET | Settings list page with search |
| `/settings/{name}` | GET | Single setting detail (htmx partial) |
| `/settings/{name}` | PUT | Update setting value |
| `/api/settings` | GET | JSON API |
| `/api/settings/{name}` | PUT | JSON API |
### Settings UI
```
┌─────────────────────────────────────────────────────────────┐
│ Eagle Admin [Games] [Health] │
├─────────────────────────────────────────────────────────────┤
│ │
│ Settings [Search: __________ ] │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ActionVigorCost (Int) │ │
│ │ Current: [15 ] Default: 15 [Save] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ BaseFoodBuyPrice (Double) │ │
│ │ Current: [0.5 ] Default: 0.5 [Save] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ... (275 settings, virtualized/paginated) ... │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Considerations
1. **Persistence**: Settings changes are in-memory only. Document that restarts reset to defaults.
2. **Validation**: Validate numeric ranges where applicable (e.g., percentages 0-100)
3. **Categories**: Consider grouping settings by prefix (AI*, Combat*, Economy*, etc.)
4. **Audit log**: Log setting changes with timestamp for debugging
---
## Feature 3: Game Rewind
### Concept
Restore a game to a previous point in its action history. This is useful for:
- Debugging issues that occurred at a specific point
- Testing "what if" scenarios
- Recovering from bugs that corrupted state
### New gRPC Endpoint
Add to `eagle.proto`:
```protobuf
message RewindGameRequest {
int64 game_id = 1;
int32 target_action_count = 2; // Rewind to state after this many actions
}
message RewindGameResponse {
bool success = 1;
string error = 2;
int32 new_action_count = 3;
int32 disconnected_clients = 4; // Number of clients that were disconnected
}
service Eagle {
// ... existing methods ...
rpc RewindGame(RewindGameRequest) returns (RewindGameResponse);
}
```
### Eagle Server Implementation
The `GameHistory` already stores `ActionWithResultingState` for each action, which includes the `GameState` after that action. Rewinding means:
1. **Validate**: Check that `target_action_count` is within valid range (0 to current count)
2. **Get target state**: Retrieve `GameState` at target action count from history
3. **Disconnect clients**: Close all human player connections (they'll need to reconnect)
4. **Replace engine**: Create new `EngineImpl` with target state and truncated history
5. **Reset AI state**: Clear any cached AI state that depends on current game state
```scala
// GameController.scala (pseudocode)
def rewindTo(targetActionCount: Int): Either[String, RewindResult] = {
if (targetActionCount < 0 || targetActionCount > engine.history.count)
return Left(s"Invalid action count: $targetActionCount")
// Get state at target point
val targetState = engine.history.stateAt(targetActionCount)
val truncatedHistory = engine.history.truncateTo(targetActionCount)
// Disconnect all human clients
val disconnectedCount = humanClients.length
humanClients.foreach(_.disconnect("Game rewound by admin"))
// Create new engine at target state
val newEngine = EngineImpl(
gameId = engine.gameId,
currentState = targetState,
history = truncatedHistory,
// ... other fields
)
// Replace controller's engine
this.engine = newEngine
Right(RewindResult(targetActionCount, disconnectedCount))
}
```
### GameHistory Enhancement
Add method to get state at a specific action count:
```scala
trait GameHistory {
// ... existing methods ...
def stateAt(actionCount: Int): GameState = {
if (actionCount == 0) initialState
else all(actionCount - 1).resultingState
}
def truncateTo(actionCount: Int): GameHistory = {
GameHistoryImpl(
initialState = initialState,
actions = all.take(actionCount)
)
}
}
```
### Admin Server Route
| Route | Method | Description |
|-------|--------|-------------|
| `/games/{id}/rewind` | POST | Rewind game (form: `target_action_count`) |
| `/games/{id}/rewind/confirm` | GET | Confirmation modal (htmx partial) |
### Rewind UI Flow
1. User views game history
2. User clicks "Rewind to here" on an action row
3. Confirmation modal appears via htmx:
```
┌─────────────────────────────────────────┐
│ Rewind Game abc123f? │
│ │
│ This will: │
│ • Restore to action 456 (Round 23) │
│ • Discard 778 subsequent actions │
│ • Disconnect 2 connected players │
│ │
│ This cannot be undone. │
│ │
│ [Cancel] [Rewind] │
└─────────────────────────────────────────┘
```
4. On confirm, POST to `/games/{id}/rewind`
5. Success: redirect to game detail showing new state
6. Error: show error message
### Safety Considerations
1. **No undo**: Rewinding discards history. Consider optional backup before rewind.
2. **Client disconnect**: All connected clients are forcibly disconnected.
3. **AI state**: Ensure AI clients restart cleanly after rewind.
4. **Concurrent access**: Lock game during rewind to prevent race conditions.
5. **Authorization**: In production, require admin authentication.
---
## Implementation Phases
### Phase 1: Web UI Foundation
**Status: Complete**
1. ✅ Set up Go templates with `embed`
2. ✅ Add Pico CSS and htmx
3. ✅ Create base layout with navigation
4. ✅ Convert `/games` to HTML with styling
5. ✅ Add game detail page with history table
6. ✅ Implement htmx infinite scroll for history
7. ✅ Reverse history order (most recent first)
8. ✅ Clickable action rows that expand to show JSON representation
9. ✅ Add `/games/{id}/action/{index}` endpoint for fetching action details
**Deliverable**: Browsable game list and history in HTML with clickable action details
### Phase 2: Settings Management
**Status: Complete**
1. ✅ Add `GetSettings` to `eagle.proto` (uses existing `AddSettings` for updates)
2. ✅ Add `getAllSettings` method to auto-generated `SettingsLoader`
3. ✅ Implement `getSettings` in `EagleServiceImpl`
4. ✅ Create settings list page with live search
5. ✅ Add inline editing with htmx
6. ✅ Modified settings are highlighted
**Deliverable**: View and edit settings via admin UI
### Phase 3: Game Rewind
**Status: Complete**
1. ✅ Add `RewindGame` to `eagle.proto`
2. ✅ Implement `stateAt` and `truncateTo` in `GameHistory`
3. ✅ Implement rewind logic in `Engine` and `GameController`
4. ✅ Add rewind confirmation (htmx `hx-confirm` dialog)
5. ✅ Handle client disconnection gracefully
6. ✅ Add rewind button to history rows
7. ✅ Implement `rewindGame` in `GamesManager` and `EagleServiceImpl`
8. ✅ Add admin server `/games/{id}/rewind` POST handler
9. ✅ Add success/error feedback UI
**Deliverable**: Rewind games to any previous action
### Phase 4: Polish
**Status: Not Started**
#### High Priority
1. **Add tests for rewind functionality**
- `PersistedHistory.truncateTo` (handles complex persisted vs recent logic)
- `InMemoryHistory.truncateTo`
- `EngineImpl.rewindTo`
- `GameController.rewindTo`
- `GamesManager.rewindGame`
2. **Improve action history display**
- Human-readable action type names (e.g., "New Round" instead of "NewRoundAction")
- Show acting faction/province when available
- Action summaries from the `summary` field in `GameHistoryEntry`
#### Medium Priority
3. **Settings improvements**
- Group settings by category prefix (AI*, Combat*, Economy*, etc.)
- Show setting descriptions where available
- Pagination for large settings lists
4. **Error handling improvements**
- Better error messages on failed operations
- Retry logic for transient gRPC failures
#### Low Priority (Nice to Have)
5. **Basic auth** - HTTP Basic Auth or OAuth for production use
6. **Audit logging** - Log admin actions with timestamps
7. **Documentation** - Usage guide, deployment notes
#### Future Considerations
- Game creation from admin UI
- Player management (view connected players, force disconnect)
- Export game history to file
- Metrics/stats dashboard
---
## Security Notes
The admin server is intended for local/trusted network use only. For production:
1. **Do not expose to public internet** without authentication
2. Consider adding HTTP Basic Auth or OAuth
3. Run on internal network or behind VPN
4. Log all admin actions for audit trail
---
## Open Questions
1. **Settings persistence**: Should we add optional persistence to disk/database?
2. **Game snapshots**: Should rewind create a backup first?
3. **Multi-admin**: Need locking if multiple admins access simultaneously?
4. **Shardok settings**: Are there Shardok (C++) settings to expose too?
-240
View File
@@ -1,240 +0,0 @@
# Eagle0 Media Asset Audit
This document catalogs all media assets in the Unity project for licensing review.
**Total Assets:** ~12,500 files | **Size:** 1.4 GB
---
## Summary by Category
| Category | Count | Notes |
|----------|-------|-------|
| Images | 10,637 | Mostly PNG icons and UI sprites |
| Audio | 1,778 | 26 music tracks + 1,752 sound effects |
| 3D Models | 52 | Bridge pack only |
| Fonts | 16 | TTF files |
---
## 1. Purchased Asset Store Packages
These are commercial Unity Asset Store purchases tied to your account:
### 4000_Fantasy_Icons
- **Location:** `Assets/4000_Fantasy_Icons/`
- **Size:** 495 MB (5,621 PNG files)
- **Contents:** Icons for armor, weapons, skills, resources
- **License:** Unity Asset Store (check invoice/account)
### GUI Pro Kit Fantasy RPG
- **Location:** `Assets/GUI Pro Kit Fantasy RPG/`
- **Size:** 117 MB (3,755 PNG files)
- **Contents:** UI sprites, animations, prefabs
- **Includes fonts:** Alata-Regular.ttf, JosefinSans-Bold.ttf
- **License:** Unity Asset Store
### Modern UI Pack v4.2.0
- **Location:** `Assets/Modern UI Pack/`
- **Size:** 40 MB (191 PNG files)
- **Author:** Michsky (support@michsky.com)
- **Website:** https://www.michsky.com
- **Includes fonts:** Open Sans family (12 variants)
- **License:** Unity Asset Store
### Pixel Fonts Megapack
- **Location:** `Assets/Pixel Fonts Megapack/`
- **Publisher ID:** 17384
- **Author:** @pixelmush_ on Twitter
- **Asset Store Link:** http://u3d.as/w4v
- **License:** Unity Asset Store
### TileableBridgePack
- **Location:** `Assets/TileableBridgePack/`
- **Size:** 3.1 MB (52 FBX models)
- **Contents:** Bridge construction pieces
- **License:** Unity Asset Store
### Fantasy Interface Sounds
- **Location:** `Assets/Fantasy Interface Sounds/`
- **Count:** 320 WAV files
- **Contents:** UI sounds (bag, book, coins, dice, etc.)
- **License:** Unity Asset Store (verify)
### Medieval Combat Sounds
- **Location:** `Assets/Medieval Combat Sounds/`
- **Count:** 1,072 WAV files
- **Contents:** Footsteps, swings, shields, weapons, magic
- **License:** Unity Asset Store (verify)
### Magic Spells Sound Effects LITE
- **Location:** `Assets/Magic Spells Sound Effects LITE/`
- **Count:** 254 WAV files
- **Contents:** Spell casting, element effects
- **Note:** "LITE" version - check if restrictions apply
- **License:** Unity Asset Store (verify)
---
## 2. Creative Commons Music (Properly Licensed)
**Location:** `Assets/Resources/Music/`
**Documentation:** `Music Credits.txt` (attribution file exists)
All 26 tracks have CC licenses with proper attribution:
| Track | Artist | License |
|-------|--------|---------|
| A Robust Crew | Darren Curtis | CC BY 3.0 |
| Asian Graveyard | Darren Curtis | CC BY 3.0 |
| Fall From Grace | Darren Curtis | CC BY 3.0 |
| Samurai Sake Showdown | Darren Curtis | CC BY 3.0 |
| Deflector | Ghostrifter Official | CC BY-SA 3.0 |
| Chase | Alexander Nakarada | CC BY 4.0 |
| Wintersong | Alexander Nakarada | CC BY 4.0 |
| One Bard Band | Alexander Nakarada | CC BY 4.0 |
| Now We Ride | Alexander Nakarada | CC BY 4.0 |
| The Northern Path | Alexander Nakarada | CC BY 4.0 |
| Victory | MaxKoMusic | CC BY-SA 3.0 |
| Sakuya2 | PeriTune | CC BY 3.0 |
| Under The Sun | Keys of Moon | CC BY 4.0 |
| One Piece of Summer | Keys of Moon | CC BY 4.0 |
| Fluffing a Duck | Kevin MacLeod | CC BY 3.0 |
| Space Jazz | Kevin MacLeod | CC BY 3.0 |
| The Ice Giants | Kevin MacLeod | CC BY 4.0 |
| Epic Cinematic Trailer ELITE | Alex-Productions | CC BY 3.0 |
| Push | Alex-Productions | CC BY 3.0 |
| Virus | Alex-Productions | CC BY 3.0 |
| Duel | Makai Symphony | CC BY-SA 3.0 |
| Dragon Castle | Makai Symphony | CC BY-SA 3.0 |
| Durandal | Makai Symphony | CC BY-SA 3.0 |
**Tracks without specific license (verify):**
- Market Day
- Shopping List
- Medieval: Victory Theme
- Tracks by Dima Koltsov (AUDIUS): No Time for Greatness, Warriors of Demacia, Forest Queen Tale, Valor, Clouds
---
## 3. CC0 / Public Domain Assets
### SimpleFileBrowser Icons
- **Location:** `Assets/Plugins/SimpleFileBrowser/Sprites/FileIcons/`
- **License:** CC0 (documented in LICENSE.txt)
- **Source:** pngrepo.com
- **Items:** Archive, Audio, Default, Drive, Folder, Image, PDF, Text, Video icons
---
## 4. Potentially Problematic Assets (Review Needed)
### Stock Images (Possible License Issues)
These appear to be stock images that may have been used as placeholders:
| File | Concern |
|------|---------|
| ~~`Assets/Eagle/79066358-stock-illustration-raster-illustration-medieval-purse-bag...jpg`~~ | **DELETED** (2025-01-04) |
| ~~`Assets/Images/kisspng-hammer-hand-saws-tool-clip-art...jpg`~~ | **DELETED** (2025-01-04) |
| ~~`Assets/Images/lee-ermy-cropped.jpg`~~ | **DELETED** (2025-01-04) |
| ~~`Assets/Eagle/images.jpeg`~~ | **DELETED** (2025-01-04) |
### Clip Art (Unknown License)
| File | Concern |
|------|---------|
| `Assets/Shardok/commandImages/bridge.png` | Clip art style wooden bridge, unknown source - **needs replacement** |
| `Assets/Images/startFire.png` | Icon, unknown source - **needs verification or replacement** |
### Shardok Sound Effects
- **Location:** `Assets/Shardok/soundEffects/`
- **Count:** 56 MP3 files
- **Contents:** Spell effects, movement, combat sounds
- **Status:** Unknown origin - may be custom or need verification
### Free Icons
- **Location:** `Assets/free_icons/`
- **Count:** 8 PNG weather icons
- **Status:** Verify "free" means commercially usable
### Terrain Hexes
- **Location:** `Assets/Terrain Hexes/`
- **Count:** 85 PNG files
- **Status:** Unknown source - verify licensing
### StrategyGameIcons
- **Location:** `Assets/StrategyGameIcons/`
- **Count:** 138 PNG files
- **Status:** Unknown source - verify licensing
---
## 5. Fonts
| Font | Location | License |
|------|----------|---------|
| Open Sans (12 variants) | Modern UI Pack | Apache 2.0 (Google Font) |
| Alata-Regular | GUI Pro Kit | SIL OFL (Google Font) |
| JosefinSans-Bold | GUI Pro Kit | SIL OFL (Google Font) |
| LiberationSans | TextMesh Pro | SIL OFL |
| NotoColorEmoji | Assets root | SIL OFL (Google) |
| Stoke-Light, Stoke-Regular | Assets root | SIL OFL (Google Font) |
All fonts appear to be open-source Google Fonts or Liberation fonts - should be fine.
---
## 6. Third-Party Code Packages
NuGet packages in `Assets/Packages/` all include LICENSE.TXT files:
- Microsoft.Extensions.* - MIT License
- System.* - MIT License
- Grpc.* - Apache 2.0
---
## Action Items
### Must Verify Before Opening Public Access:
1. ~~**Stock images** - The JPG files with stock image filenames need review.~~ **DONE** - Deleted lee-ermy, kisspng, stock-illustration, Yosemite Sam, and images.jpeg (2025-01-04)
2. **Clip art images** - Unknown license, need replacement with properly licensed alternatives:
- `Assets/Shardok/commandImages/bridge.png` - wooden bridge icon
- `Assets/Images/startFire.png` - fire icon
3. **Shardok sound effects** - 56 MP3 files of unknown origin. Either:
- Document their source
- Replace with known-licensed alternatives
- Confirm they were custom-created
4. **Terrain Hexes** - 85 hex tiles of unknown source
- **TODO:** Confirm if this is a Unity Asset Store purchase (owner believes it is)
5. **StrategyGameIcons** - 138 icons of unknown source
- **TODO:** Investigate origin - check Unity Asset Store purchase history
6. **AUDIUS music tracks** - Verify Dima Koltsov tracks are licensed for commercial use
7. **Discord logo** (`Eagle/Discord-Logo-Blurple.png`) - Likely fine for "Login with Discord" button per Discord brand guidelines, but verify usage complies with their terms
### Already Safe:
- All Asset Store purchases (license tied to your account)
- CC-licensed music (attribution in Music Credits.txt)
- CC0 SimpleFileBrowser icons
- Google Fonts / Liberation fonts
- NuGet packages
---
## Recommendation
Before removing HTTP basic auth:
1. ~~Delete or replace the 4 suspicious JPG/JPEG files in `Assets/Eagle/` and `Assets/Images/`~~ **DONE** (2025-01-04)
2. Replace clip art images (`bridge.png`, `startFire.png`) with properly licensed alternatives (e.g., from [game-icons.net](https://game-icons.net) CC BY 3.0)
3. Verify source of `Assets/Shardok/soundEffects/` MP3s
4. Verify source of `Assets/Terrain Hexes/` and `Assets/StrategyGameIcons/`
5. If any are from early development with unclear licensing, replace them
The bulk of your assets (95%+) are properly licensed Asset Store purchases or CC content.
-280
View File
@@ -1,280 +0,0 @@
# CommandProto Usage Analysis in shardok/ai
This document analyzes all remaining usages of `CommandProto` (protocol buffer representation) in the AI code and identifies opportunities to eliminate proto conversion by using `ShardokCommand` directly.
## Summary
**Total CommandProto usages found:** 42 locations across 9 files
**Eliminated:** 6 usages (14%) - ✅ **Phase 1 Complete**
**Can be eliminated:** ~14 usages (33%)
**Must keep (for now):** ~22 usages (53%)
---
## Files with CommandProto Usage
### 1. AICommandFilter.cpp (6 usages) - ✅ **COMPLETED** (PR #4505)
**Location:** Lines 146, 189, 252, 356, 387, 428
**Original usage:**
```cpp
const auto cmdProto = cmd.GetCommandProto();
if (!cmdProto.has_target()) { ... }
const auto& targetCoords = cmdProto.target();
if (!cmdProto.has_actor()) { ... }
const auto unitId = cmdProto.actor().value();
```
**Replaced with:**
```cpp
const int targetRow = cmd.GetTargetRow();
const int targetCol = cmd.GetTargetColumn();
if (targetRow < 0 || targetCol < 0) {
throw ShardokInternalErrorException("Command missing required target");
}
const Coords targetCoords(targetRow, targetCol);
const int actorId = cmd.GetActorUnitId();
if (actorId < 0) {
throw ShardokInternalErrorException("Command missing required actor");
}
```
**Status:****ELIMINATED** - Replaced with direct accessors + exception handling
**Impact:** Eliminated 6 proto conversions in hot path (command filtering)
**Completed:** Phase 1, PR #4505
---
### 2. ShardokAIClient.cpp (8 usages)
**Location:** Lines 83, 86, 87, 102, 105, 237, 261, 311, 356
**Usage breakdown:**
#### a) Command validation (lines 83-87)
```cpp
void CheckCommand(const CommandProto &realDescriptor, const CommandProto &guessedDescriptor) {
differencer.IgnoreField(CommandProto::descriptor()->FindFieldByNumber(
CommandProto::kFollowUpCommandTypesFieldNumber));
```
**Status:****MUST KEEP** - Uses protobuf reflection for comparison
**Reason:** Comparing proto messages for correctness checking requires proto API
#### b) GetAvailableCommandProtos calls (lines 105, 356)
```cpp
const auto guessedCommands = guessedEngine.GetAvailableCommandProtos(playerId, false);
if (const auto &availableCommands = engine.GetAvailableCommandProtos(playerId, false);
```
**Status:****CAN REPLACE** - Should use `GetAvailableCommandsForAIPlayer()` instead
**Impact:** This is a major conversion point - converts entire command list to protos
**Priority:** HIGH (converts all commands to proto unnecessarily)
#### c) Strategy selector methods (lines 102, 237, 261, 311)
```cpp
const vector<CommandProto> &realAvailableCommands) const -> CommandChoiceResults
```
**Status:****CAN REPLACE** - Depends on fixing strategy selector signatures
**Priority:** MEDIUM (depends on other refactors)
---
### 3. IterativeDeepeningAI.cpp/hpp (4 usages)
**Location:** Lines 41, 272 (cpp), 73, 96 (hpp)
**Current usage:**
```cpp
const std::vector<CommandProto>& commands,
```
**Status:****CAN REPLACE** - These methods should accept `CommandListSPtr` instead
**Impact:** Major - this is the main AI search algorithm
**Priority:** HIGH (core AI algorithm)
**Note:** IterativeDeepeningAI already receives commands as proto vectors. The conversion happens upstream at the entry point. Need to trace back to find where `GetAvailableCommandProtos` is called.
---
### 4. AIFleeDecisionCalculator.cpp/hpp (6 usages)
**Location:** Lines 17, 38, 39, 62, 63 (hpp), 18, 19, 137, 138 (cpp)
**Current usage:**
```cpp
const vector<CommandProto>& availableCommands,
const vector<CommandProto>::const_iterator& fleeCommand,
```
**Status:****CAN REPLACE** - Should use `CommandListSPtr` and indices instead
**Impact:** Flee decision logic could avoid proto conversion
**Priority:** MEDIUM
---
### 5. AIAttackerStrategySelector.cpp/hpp (2 usages)
**Location:** Line 30 in both files
**Current usage:**
```cpp
const vector<CommandProto>& availableCommands) -> AIStrategy
```
**Status:** ⚠️ **PARTIALLY REPLACEABLE** - Currently doesn't use the commands parameter
**Current implementation:**
```cpp
const vector<CommandProto>& /*availableCommands*/) -> AIStrategy {
// Parameter is commented out - not used!
return AIStrategy::DEFAULT;
}
```
**Priority:** LOW (parameter unused, but signature should be consistent)
---
### 6. AICommandEvaluator.hpp (1 usage)
**Location:** Line 27
**Current usage:**
```cpp
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
```
**Status:** ⚠️ **CHECK USAGE** - Type alias, need to check if used
**Priority:** LOW (just a type alias)
---
### 7. AIScoreCalculator.hpp (1 usage)
**Location:** Line 24
**Current usage:**
```cpp
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
```
**Status:** ⚠️ **CHECK USAGE** - Type alias, need to check if used
**Priority:** LOW (just a type alias)
---
### 8. AIWaterCrossingCommandChooser.hpp (1 usage)
**Location:** Line 20
**Current usage:**
```cpp
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
```
**Status:** ⚠️ **CHECK USAGE** - Type alias, need to check if used
**Priority:** LOW (just a type alias)
---
## Key Conversion Points (Entry Points)
### ShardokEngine::GetAvailableCommandProtos()
This method converts the entire command list from `CommandListSPtr` to `vector<CommandProto>`.
**Current flow:**
```
ShardokEngine::GetAvailableCommandsForAIPlayer() → CommandListSPtr
↓ (conversion)
ShardokEngine::GetAvailableCommandProtos() → vector<CommandProto>
AI algorithms (IterativeDeepeningAI, etc.)
```
**Desired flow:**
```
ShardokEngine::GetAvailableCommandsForAIPlayer() → CommandListSPtr
↓ (no conversion!)
AI algorithms use CommandSPtr directly
```
---
## Recommendations by Priority
### HIGH Priority (Performance-critical hot paths)
1. **AICommandFilter.cpp (6 usages)**
- Replace `cmd.GetCommandProto()` with direct accessor methods
- Use `GetActorUnitId()`, `GetTargetRow()`, `GetTargetColumn()`
- Impact: Eliminates 6 proto conversions per filtered command
2. **ShardokAIClient.cpp - GetAvailableCommandProtos calls**
- Replace calls to `GetAvailableCommandProtos()` with `GetAvailableCommandsForAIPlayer()`
- Impact: Eliminates conversion of entire command list
3. **IterativeDeepeningAI**
- Change signature from `vector<CommandProto>` to `CommandListSPtr`
- Impact: Main AI search algorithm avoids proto conversion
### MEDIUM Priority
4. **AIFleeDecisionCalculator**
- Change to use `CommandListSPtr` and indices
- Impact: Flee decision logic avoids proto
5. **ShardokAIClient strategy methods**
- Update signatures to use `CommandListSPtr`
- Cascades to strategy selectors
### LOW Priority
6. **Type aliases**
- Remove unused `using CommandProto` declarations
- Clean up imports
---
## Migration Strategy
### Phase 1: Low-hanging fruit (AICommandFilter) - ✅ **COMPLETED** (PR #4505)
- ✅ Replaced 6 proto conversions with direct accessor calls
- ✅ Added exception handling for missing actor/target data
- ✅ No signature changes needed
- ✅ Immediate performance benefit
- **PR:** #4505
### Phase 2: Entry point (ShardokAIClient)
- Replace `GetAvailableCommandProtos()` calls with `GetAvailableCommandsForAIPlayer()`
- Update method signatures in ShardokAIClient
### Phase 3: Core AI (IterativeDeepeningAI)
- Change IterativeDeepeningAI to accept `CommandListSPtr`
- This is the biggest change but has highest impact
### Phase 4: Supporting systems
- Update AIFleeDecisionCalculator
- Update strategy selectors
- Clean up type aliases
### Phase 5: Validation code
- Keep proto-based validation as-is (uses reflection)
- Consider if validation is still needed in production
---
## Notes
- **MCTS already converted**: The MCTS code path already uses `CommandListSPtr` directly
- **Proto still needed**: For serialization/network communication (not in AI hot path)
- **Validation**: Proto comparison in CheckCommand() should remain (uses proto reflection)
---
## Estimated Impact
**Proto conversions eliminated:** ~20-25 per command choice
**Performance gain:** Eliminates hundreds of allocations per AI decision
**Code simplification:** Removes proto conversion layer from AI
**Before:**
```
Command → Proto → AI Decision
```
**After:**
```
Command → AI Decision (direct)
```
File diff suppressed because it is too large Load Diff
-388
View File
@@ -1,388 +0,0 @@
# Deproto Migration Plan
## Vision
**Protocol buffers should only be used at the edges** — for network serialization (gRPC) and disk persistence. Inside the Eagle game engine, all logic should operate on native Scala models.
```
┌─────────────────────────────────────────────────────────────────────┐
│ GRPC BOUNDARY │
│ EagleServiceImpl.scala ←→ Proto Messages ←→ Unity Client │
└─────────────────────────────────────────────────────────────────────┘
GameStateConverter
┌─────────────────────────────────────────────────────────────────────┐
│ SCALA ENGINE │
│ │
│ GameStateC ───→ Actions ───→ ActionResultT ───→ New GameStateC │
│ ↑ │ │
│ │ (Pure Scala models) │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ HeroC, FactionC, ProvinceC, BattalionC, ArmyC, etc. │
└─────────────────────────────────────────────────────────────────────┘
GameStateConverter
┌─────────────────────────────────────────────────────────────────────┐
│ PERSISTENCE BOUNDARY │
│ GameHistory.scala ←→ Proto Messages ←→ File/Database │
└─────────────────────────────────────────────────────────────────────┘
```
---
## Current State
### Completed Phases
| Phase | Status | Summary |
|-------|--------|---------|
| Phase 1: GameStateC | **Complete** | Scala `GameState` model with 22 fields |
| Phase 2: EngineImpl | **Complete** | Holds Scala `GameState` internally |
| Phase 3: GameHistory | **Complete** | `stateAfter` returns Scala GameState |
| Phase 4: ActionResultT | **Complete** | All 59 actions return `ActionResultT` |
| Phase 5: Action Base Classes | **Complete** | All `RandomSequentialResultsAction` and `DeterministicSingleResultAction` converted to T-type base classes |
| Phase 5b: Base Class Cleanup | **Complete** | `RandomSequentialResultsAction` and `DeterministicSingleResultAction` deleted |
| Phase 5c: RoundPhaseAdvancer Actions | **Complete** | All actions called by RoundPhaseAdvancer accept Scala GameState |
| Phase 5d: RoundPhaseAdvancer Itself | **Complete** | RoundPhaseAdvancer.checkForPhaseAdvancement takes Scala GameState |
### Phase 5c/5d Progress (Complete)
`RoundPhaseAdvancer.checkForPhaseAdvancement` now accepts Scala `GameState` and `ActionResultApplier` directly (PR #4677).
| Action | PR | Status |
|--------|-----|--------|
| `PrisonerExchangeAction` | #4670 | ✅ Merged |
| `PerformForcedTurnBackAction` | #4671 | ✅ Merged |
| `PerformHeroDeparturesAction` | #4672 | ✅ Merged |
| `RequestFreeForAllBattlesAction` | #4673 | ✅ Merged |
| `EndPlayerCommandsPhaseAction` | #4674 | ✅ Merged |
| `EndDiplomacyResolutionPhaseAction` | #4675 | ✅ Merged |
| `RoundPhaseAdvancer` itself | #4677 | ✅ Merged |
### EngineImpl Progress
| Change | PR | Status |
|--------|-----|--------|
| `recursiveTransform` deleted | #4677 | ✅ Merged |
| `recursiveTransformT` uses `RandomStateTSequencer` | #4677 | ✅ Merged |
### Current Architecture
**ActionResultT Production (100% Complete):**
- All actions produce `ActionResultT`
- Conversion to `ActionResultProto` happens via `ActionResultProtoConverter.toProto()`
- No direct `ActionResultProto` construction outside the converter
**ActionResultProto Consumption (Next Target):**
- `ActionResultProtoApplierImpl` - applies proto results to proto GameState
- `RoundPhaseAdvancer` - calls converter, passes protos to applier
- `InMemoryHistory` / `PersistedHistory` - stores proto results
- Service layer (`GameController`, `GamesManager`, etc.) - uses proto for client communication
---
## Phase 6: Migrate to ActionResultT Consumers
### Objective
Eliminate internal consumption of `ActionResultProto`. Everything inside the engine should work with `ActionResultT`.
### Current Flow (Proto-Heavy)
```
Action.execute()
→ ActionResultT
→ ActionResultProtoConverter.toProto()
→ ActionResultProto
→ ActionResultProtoApplierImpl.applyActionResults()
→ GameStateProto
→ GameStateConverter.fromProto()
→ GameStateC
```
### Target Flow (T-Types Throughout)
```
Action.execute()
→ ActionResultT
→ ActionResultApplier.applyActionResults()
→ GameStateC
(Proto conversion only at boundaries)
```
### Key Files to Convert
**Tier 1 - Core Applier:****Complete**
```
src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultApplierImpl.scala
```
`ActionResultApplier` applies `ActionResultT` directly to Scala `GameState`. The legacy `ActionResultTApplierImpl` wraps it and converts to/from proto for callers that still need proto types.
**Tier 2 - RoundPhaseAdvancer:****Complete**
```
src/main/scala/net/eagle0/eagle/library/RoundPhaseAdvancer.scala
```
Now accepts Scala `GameState` and `ActionResultApplier`. Only converts to proto lazily for `AvailableCommandsFactory` calls.
**Tier 3 - Sequencers:**
```
src/main/scala/net/eagle0/eagle/library/actions/impl/common/RandomStateTSequencer.scala
src/main/scala/net/eagle0/eagle/library/actions/impl/common/RandomStateProtoSequencer.scala
```
Modify `RandomStateTSequencer` to thread Scala `GameState` throughout (currently converts to proto internally). Then evaluate whether `RandomStateProtoSequencer` is still needed at all.
**Current State**: `RandomStateTSequencer` accepts Scala `GameState` via its `apply()` method but internally converts to proto. All callback methods (`withRandomActionResult`, `withActionResults`, etc.) pass `GameStateProto` to callers, forcing actions that use the sequencer to work with proto types internally.
**Target State**: Create a fully protoless sequencer where:
1. `lastState` returns Scala `GameState` (not `lastStateProto`)
2. All callback methods pass Scala `GameState` to callers
3. Actions using the sequencer can be fully protoless
**Migration Path**:
1. Add `lastState: GameState` method alongside `lastStateProto` (non-breaking)
2. Add parallel callback methods that pass Scala GameState (e.g., `withScalaActionResult`)
3. Migrate actions one by one to use the new Scala-based callbacks
4. Once all actions migrated, deprecate/remove proto-based callbacks
5. Remove `lastStateProto` once no longer used
**RandomStateSequencer Migration Progress** (PR #4679 introduced protoless `RandomStateSequencer`):
| Action | Status |
|--------|--------|
| `TruceTurnBackPhaseAction` | ✅ Migrated (PR #4680) |
| `EndHandleRiotsPhaseAction` | ✅ Migrated (PR #4684) |
| `PerformVassalCommandsPhaseAction` | ✅ Migrated |
| `PerformVassalDefenseDecisionsAction` | ✅ Migrated |
| `EndVassalCommandsPhaseAction` | ✅ Migrated |
| `PerformReconResolutionAction` | ✅ Migrated |
| `NewRoundAction` | ✅ Migrated (PR #4698) |
| `EndBattleAftermathPhaseAction` | ✅ Migrated (PR #4699) |
| `EndDiplomacyResolutionPhaseAction` | ✅ Migrated |
| `PerformUnaffiliatedHeroesAction` | ✅ Migrated |
| `EngineImpl.recursiveTransformT` | ✅ Migrated (PR #4704) |
| `ProtolessSequentialResultsActionWrapper` | ✅ Migrated (PR #4705) |
| `LegacyRandomStateTSequencer` | ✅ **Deleted** (PR #4705) |
**TCommandFactory Extraction** (PR #4684):
To enable lightweight mocking of command creation in tests, `TCommandFactory` trait was extracted from `CommandFactory`. This allows tests to mock just the `makeTCommand` method without pulling in all 40+ command dependencies that `CommandFactory` requires.
- `TCommandFactory` - lightweight trait with just `makeTCommand`
- `CommandFactory extends TCommandFactory` - maintains backward compatibility
- Actions accepting command factories now use `TCommandFactory` type for better testability
**Tier 4 - History APIs:**
```
src/main/scala/net/eagle0/eagle/service/InMemoryHistory.scala
src/main/scala/net/eagle0/eagle/service/PersistedHistory.scala
```
Change APIs to vend Scala `GameState` and `ActionResultT` instead of proto versions. `PersistedHistory` converts to proto internally for disk persistence; `InMemoryHistory` doesn't need proto at all.
### ActionResultProto Consumer Inventory
| File | Usage | Status |
|------|-------|--------|
| `ActionResultApplierImpl.scala` | Applies ActionResultT to Scala GameState | ✅ **Complete** |
| `ActionResultTApplierImpl.scala` | Legacy wrapper - converts to/from proto | Keep until all callers migrated |
| `RoundPhaseAdvancer.scala` | Uses Scala GameState | ✅ **Complete** |
| `RandomStateSequencer.scala` | Threads Scala GameState | ✅ **Complete** |
| `VigorXPApplier.scala` | Has both proto and Scala methods | Scala method exists, delete proto method when unused |
| `PerformForcedTurnBackAction.scala` | Fully protoless | ✅ **Complete** |
| `ResolveBattleAction.scala` | ✅ Fully protoless | Complete (PR #5048) |
| `InMemoryHistory.scala` | Stores proto results | Pending - vend Scala types |
| `PersistedHistory.scala` | Stores proto results | Pending - vend Scala types, convert for disk |
| `GameController.scala` | Uses proto for client communication | Keep proto (gRPC boundary) |
### Remaining Proto Usage in Actions
**Progress: 52 of 52 action files (100%) are fully protoless.**
All action files have been migrated to use Scala types:
| Action | Status | Notes |
|--------|--------|-------|
| `ResolveBattleAction` | ✅ Complete | PR #5048 - uses Scala GameState and ActionResultApplier |
| `PerformVassalCommandsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
| `EndHandleRiotsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
| `PerformVassalDefenseDecisionsAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
| `EndVassalCommandsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
**Note:** `NewRoundAction` is now fully protoless after converting `ChronicleEventGenerator` to return Scala `ChronicleEvent` types directly.
**Deleted Dead Code:**
- `UnaffiliatedHeroMovedAction` - Was never called; `PerformUnaffiliatedHeroesAction.heroMovedResult` constructs `ActionResultC` directly
- `HeroBackstoryUpdateActionGenerator.fromGameState` - Dead method that converted proto to Scala; only `apply(GameState)` is used
**Note**: `PerformReconResolutionAction` and `EndBattleAftermathPhaseAction` are now fully protoless after:
1. Migrating `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` to use Scala `ProvinceView`
2. Adding Scala overload of `ProvinceViewFilter.withdrawnFromProvinceView`
### Estimated Effort (Remaining)
| Component | Lines | Complexity | Blocks |
|-----------|-------|------------|--------|
| History API updates | ~100 | Low | - |
| **Total Remaining** | **~100** | | |
**Completed:**
-`ChronicleEventGenerator` converted to return Scala `ChronicleEvent` types directly
-`CommandChoiceHelpers` migrated to Scala types
-`ResolveBattleAction` refactored to use Scala GameState and ActionResultApplier (PR #5048)
### Enum Type Migrations
Proto enums are being converted to Scala sealed traits with converters at boundaries:
| Enum | Scala Type | Status | Notes |
|------|------------|--------|-------|
| `DiplomacyOfferStatus` | `Status` sealed trait | ✅ **Complete** | PR #5093 - `EligibleDiplomacyStatuses` uses Scala types internally |
| `RoundPhase` | `RoundPhase` sealed trait | Partial | Some usages converted |
| `BattalionType` | `BattalionType` sealed trait | Partial | Some usages converted |
**DiplomacyOfferStatus Migration (PR #5093):**
- `EligibleDiplomacyStatuses.scala` now returns `Vector[Status]` instead of `Vector[DiplomacyOfferStatus]`
- Call sites in `AvailableResolve*CommandFactory` files convert to proto via `StatusConverter.toProto` at the boundary
- This pattern should be applied to other proto enums
### CommandChoiceHelpers Migration Status
Several command selectors have already been converted to use Scala types:
| File | Status | Notes |
|------|--------|-------|
| `AttackCommandChooser.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
| `AlmsCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
| `FoodConsumptionUtils.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `RoundPhase` |
| `MarchSuppliesHelpers.scala` | ✅ **Protoless** | Uses `BattalionT` |
| `CombatUnitSelector.scala` | ✅ **Protoless** | Uses `HeroT`, `BattalionT`, `BattalionType` |
| `ExpandCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `FactionT` |
| `ImproveCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `HeroT` |
| `OrganizeCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `BattalionT`, `BattalionType` |
| `RansomOfferHelpers.scala` | ✅ **Protoless** | Uses Scala `GameState`, `FactionT` |
| `CommandChoiceHelpers.scala` | ✅ **Protoless** | Uses Scala `GameState` throughout |
| `ProvinceGoldSurplusCalculator.scala` | ✅ **Protoless** | Uses Scala types |
**All CommandChoiceHelpers selectors have been migrated to Scala types.**
### Progress Summary
| Metric | Value |
|--------|-------|
| Action files fully protoless | 52 / 52 (100%) ✅ |
| Proto usages in remaining actions | 0 |
| Next target | See "Next Candidates" section below |
### Next Candidates
Priority candidates for further deproto work:
1. **More Enum Migrations** - Apply the `DiplomacyOfferStatus` pattern to other proto enums:
- Files importing `net.eagle0.eagle.common.round_phase.RoundPhase` (proto) could use Scala `RoundPhase`
- Files importing `net.eagle0.eagle.common.battalion_type.BattalionType` (proto) could use Scala `BattalionType`
2. **AvailableCommandsFactory Files** - Many still use proto `GameState` internally:
- These files build proto `AvailableCommand` messages but could use Scala types for internal logic
- Convert to accept Scala `GameState`, only convert fields to proto when building the response
3. **History APIs** - `InMemoryHistory` and `PersistedHistory`:
- Change to vend Scala `GameState` and `ActionResultT` instead of proto versions
- `PersistedHistory` converts to proto internally for disk persistence
### Validation
- [x] `ActionResultApplier` created and tested
- [x] `RandomStateSequencer` threads Scala GameState throughout
- [x] `RoundPhaseAdvancer` uses T-types internally
- [x] `ProvinceViewFilter` has Scala overload for server-side use (PR #4752)
- [x] `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` use Scala `ProvinceView`
- [x] `ProvinceViewFilter.withdrawnFromProvinceView` has Scala overload
- [x] `CommandChoiceHelpers` uses Scala types ✅
- [x] All action files (52/52) are fully protoless ✅
- [ ] `ProvinceViewFilter` faction-filtered views use Scala types
- [ ] History APIs vend Scala types
- [ ] No `ActionResultProtoConverter.toProto()` calls except at persistence/gRPC boundaries
- [ ] All tests pass
---
## Phase 7: Clean Up Legacy Utilities
### Objective
Remove remaining direct proto imports from utility classes.
### Files to Modify
| File | Status |
|------|--------|
| `CommandChoiceHelpers.scala` | Accepts proto `GameState`; blocks full deproto of `PerformVassalCommandsPhaseAction` and `PerformVassalDefenseDecisionsAction` |
| `LegacyProvinceUtils.scala` | Replace with `ProvinceUtils.scala` - `hasImminentRiot` added (PR #4683) |
| `LegacyFactionUtils.scala` | Replace proto imports with `FactionT` |
| `LegacyUnaffiliatedHeroUtils.scala` | Replace proto imports with Scala models |
| `BattalionTypeLoader.scala` | Keep proto for file loading, convert immediately after |
| `BeastUtils.scala` | **Complete** - now uses Scala `BeastInfo` only |
### View Filters (Partially Complete)
The view filter utilities now have Scala overloads for server-side use:
| File | Status | Notes |
|------|--------|-------|
| `ProvinceViewFilter.scala` | **Partial** | `filteredProvinceView(ProvinceT, ScalaGameState)` added (PR #4752) |
| `ArmyFilter.scala` | **Partial** | `filterArmy(ScalaArmy, Map[BattalionId, BattalionT], Option[FactionId])` added |
| `BattalionViewFilter.scala` | **Complete** | Uses Scala `BattalionT` throughout |
| `GameStateViewFilter.scala` | Pending | Uses proto types throughout |
| `GameStateViewDiffer.scala` | Pending | Works with view protos |
**Unblocked Actions** (PR #4752):
- `EndBattleAftermathPhaseAction` - can now use `filteredProvinceView(province, scalaGameState)`
- `PerformReconResolutionAction` - can now use Scala overload
- `GameStateFactionExtensions` - can now use `updatedReconnedProvinces` with Scala types
**Remaining Work**:
- Faction-filtered `filteredProvinceView(Province, GameState, FactionId)` still uses proto types
- `withdrawnFromProvinceView` still uses proto types
- These are needed for client-facing views with visibility restrictions
---
## Phase 8: Verify Boundaries
### Objective
Confirm protos are used correctly at boundaries — and ONLY there.
### Expected Proto Usage (Keep)
- `EagleServiceImpl.scala` - gRPC boundary
- `InMemoryHistory.scala` / `PersistedHistory.scala` - Persistence boundary
- `*Converter.scala` - Explicit conversion utilities
- `*Loader.scala` - File loading utilities
### Expected No Proto Usage (Verify)
- `/library/actions/impl/` - Pure Scala models
- `/library/util/` - Pure Scala models (except loaders)
- `/model/state/` - Pure Scala models
---
## Open Questions
1. **Persistence Format**: Currently game state is persisted as proto. Should we keep proto for persistence (good for schema evolution) or switch to a different format?
2. **Shardok Integration**: `ResolveBattleAction` communicates with Shardok. Should the Shardok interface use protos (external service) or Scala models?
3. **View Generation**: `GameStateViewDiffer` works with view protos for client updates. Views need Scala models (`ProvinceViewT`, etc.) to allow actions like `EndBattleAftermathPhaseAction` to be fully protoless. The Scala views would be converted to proto only at the gRPC boundary when sending updates to clients.
---
## Success Criteria
### Code Quality
- [ ] Zero proto imports in `/library/actions/` (except boundaries)
- [ ] Zero proto imports in `/library/` utilities (except loaders)
- [ ] `GameStateT` used throughout engine internals
- [ ] Proto usage limited to: `EagleServiceImpl`, loaders, converters, persistence
### Architecture
- [ ] Clear separation: Scala models (internal) vs Proto (boundaries)
- [ ] Converters as the only bridge between domains
- [ ] No "proto creep" into business logic
-159
View File
@@ -1,159 +0,0 @@
# Hetzner Setup Guide
This guide walks through setting up Hetzner Cloud infrastructure for running Shardok on-demand compute.
## Prerequisites
- All code PRs merged (#4990, #4996, #4998, #5001, #5009)
- Access to DigitalOcean Container Registry (for pulling Shardok ARM64 image)
---
## Step 1: Create Hetzner Cloud Account
1. Go to https://console.hetzner.cloud/
2. Sign up and add payment method
3. Create a new project (e.g., "eagle0")
---
## Step 2: Generate Hetzner API Token
1. In Hetzner Console → Security → API Tokens
2. Click "Generate API Token"
3. Give it **Read & Write** permissions
4. Copy the token (you'll only see it once)
---
## Step 3: Generate Shardok Auth Token
Generate a 256-bit random token for Eagle-Shardok authentication:
```bash
openssl rand -hex 32
```
Save this output - it's the shared secret between Eagle and Shardok.
---
## Step 4: Store Secrets in GitHub Actions
Add these secrets in GitHub → Settings → Secrets and variables → Actions:
| Secret Name | Description |
|-------------|-------------|
| `HETZNER_API_TOKEN` | From Step 2 - for Hetzner API calls |
| `SHARDOK_AUTH_TOKEN` | From Step 3 - shared secret for gRPC auth |
Note: `DO_REGISTRY_TOKEN` already exists and will be used for Hetzner to pull container images.
These secrets will be passed to Eagle at runtime via `docker_build.yml`, similar to how `OPENAI_API_KEY` and other secrets are handled.
---
## Step 5: DNS Setup (for Let's Encrypt)
You need a domain pointing to the Shardok instance for TLS certificates.
### Option A: Floating IP (Recommended)
1. In Hetzner Console → Networking → Floating IPs
2. Create a **Floating IPv6** in **Hillsboro, Oregon (hil)** region
- IPv6 costs €1/month vs €3/month for IPv4
- Hillsboro has better latency to DigitalOcean SFO than Ashburn
- Server-to-server communication works fine with IPv6-only
3. Point `shardok.prod.eagle0.net` to this IP via AAAA record
4. The ShardokInstanceManager will attach this IP to instances on spin-up
**Location choice**: Hillsboro, OR (`hil`) is recommended for US West Coast. Same pricing as Ashburn (`ash`).
### Option B: Dynamic DNS
Update DNS programmatically when instance spins up. More complex but avoids floating IP cost.
---
## Step 6: Upload SSH Key to Hetzner
For debugging access to instances:
1. In Hetzner Console → Security → SSH Keys
2. Click "Add SSH Key"
3. Paste your public key (e.g., `~/.ssh/id_rsa.pub`)
4. Give it a name (e.g., "eagle-deploy")
---
## Step 7: Wire Security Config into Eagle
Update Eagle's startup code to use the security config when connecting to remote Shardok:
```scala
val securityConfig = ShardokSecurityConfig(
useTls = true,
authToken = Some(sys.env("SHARDOK_AUTH_TOKEN"))
)
val channel = ServerSetupHelpers.newChannel(
"shardok.prod.eagle0.net",
50051,
securityConfig
)
```
---
## Testing
### Manual Instance Spin-up
Test the Hetzner integration by triggering instance creation:
```scala
val manager = new ShardokInstanceManager(
hetznerApiToken = sys.env("HETZNER_API_TOKEN"),
// ... other config
)
manager.ensureInstanceRunning()
```
### Verify TLS and Auth
1. Instance spins up and gets Let's Encrypt certificate
2. Eagle connects via TLS
3. Auth token is validated on each request
---
## Cost Estimate
| Component | Cost |
|-----------|------|
| CAX41 (16 ARM cores) | ~$0.04/hour |
| Floating IP | ~$4/month |
| Typical usage (20 hrs/week) | ~$3.50/month compute |
**Total: ~$7-8/month** for typical usage.
---
## Troubleshooting
### Instance won't start
- Check Hetzner API token has Read & Write permissions
- Verify you're using the correct region (`hil` for Hillsboro OR, or `ash` for Ashburn VA)
### TLS certificate fails
- Ensure DNS points to the instance IP before certbot runs
- Check port 80 is open for Let's Encrypt HTTP-01 challenge
### Auth failures
- Verify `SHARDOK_AUTH_TOKEN` matches on both Eagle and Shardok
- Check the token file is readable by Shardok container
### Can't pull container image
- Ensure `DO_REGISTRY_TOKEN` is passed to cloud-init
- Verify the ARM64 image exists: `registry.digitalocean.com/eagle0/shardok-server:arm64-latest`
-383
View File
@@ -1,383 +0,0 @@
# Plan: Extract OAuth to Go Service
## Goal
Move OAuth authentication handling from the Eagle Scala server into a separate Go service. This simplifies Eagle (gRPC-only, no HTTP), reduces complexity, and sets up for potentially moving JWT validation outside Eagle too.
## Architecture Decision: Sidecar Service (Not DO Functions)
**Recommendation: Go sidecar service on the same droplet, in a separate container**
**Why not DO Functions:**
- OAuth requires **stateful sessions** (pendingOAuth/completedOAuth maps with 10-min TTL)
- Client polling pattern (every 2 seconds) would incur high function invocation costs
- Cold start latency problematic for auth flows
- State would require external store (Redis), adding complexity
**Why sidecar (separate container):**
- Simple process on same droplet, minimal network latency
- In-memory state management (like current Scala impl)
- Easy to monitor/debug alongside Eagle
- Can share filesystem for key files (RSA keys) via volume mounts
- **Independent deployment**: Deploying Eagle doesn't restart auth service (and vice versa)
- **Independent scaling**: Could move to separate droplet later if needed
## Current Architecture (What Exists)
```
Unity Client
├── GetOAuthUrl RPC → Eagle AuthServiceImpl → OAuthService.getAuthUrl()
├── [User browser auth] → HTTP callback → OAuthHttpHandler → OAuthService.handleCallback()
├── CheckOAuthStatus RPC (polling) → AuthServiceImpl → OAuthService.checkStatus()
└── All other RPCs include JWT → AuthorizationInterceptor validates
```
**Key files:**
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth flow, state management
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - JWT creation/validation
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD (persisted)
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC Auth service
- `src/main/scala/net/eagle0/eagle/service/OAuthHttpHandler.scala` - HTTP callback handler
## Target Architecture (Phase 1)
```
Unity Client
├── GetOAuthUrl RPC ──────────────┐
├── CheckOAuthStatus RPC (polling)├──→ Eagle (port 40032) ──proxy──→ Go Auth Container (port 40033)
├── RefreshToken RPC ─────────────┘ │
├── [User browser] → HTTP callback ────────────────────────────────────────┤
│ ↓
│ (Internal gRPC: GetOrCreateUser, GetUser)
│ ↓
└── Game RPCs with JWT ─────────────────────→ Eagle (port 40032) ← JWT validation stays here
[Same Droplet]
┌─────────────────────────────────────────────────────────────────────────────┐
│ │
│ ┌──────────────────────┐ ┌────────────────────────────────────┐ │
│ │ Go Auth Container │◄────────►│ Eagle Container │ │
│ │ (eagle0-auth) │ internal │ (eagle0-server) │ │
│ │ │ gRPC │ │ │
│ │ - OAuth flow │ │ - JWT validation │ │
│ │ - JWT creation │ │ - UserService (persistence) │ │
│ │ - HTTP callback │ │ - Game logic │ │
│ └──────────────────────┘ └────────────────────────────────────┘ │
│ │ │ │
│ └────────────────┬───────────────────────┘ │
│ ▼ │
│ /etc/eagle0/keys/ (shared volume) │
│ - private.pem │
│ - public.pem │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Component Responsibilities
### Go Auth Service (NEW - separate container)
- **OAuth flow**: getAuthUrl, handleCallback (HTTP), checkStatus
- **State management**: pendingOAuth, completedOAuth maps with TTL
- **JWT creation**: Issue access/refresh tokens (shares RSA private key with Eagle)
- **Token refresh**: Validate refresh token, issue new access token
- Calls Eagle's internal UserService gRPC to find/create users
### Eagle Server (SIMPLIFIED)
- **JWT validation**: AuthorizationInterceptor stays (validates tokens on game RPCs)
- **UserService**: Stays in Eagle (user persistence, display name logic)
- **New internal gRPC**: Expose GetOrCreateUser, GetUser for Go service to call
- **Proxy (Phase 1)**: Forward OAuth RPCs to Go service
- **Remove (Phase 2)**: OAuthService, OAuthHttpHandler, HTTP server setup
### Unity Client (NO CHANGES in Phase 1)
- Eagle proxies Auth RPCs to Go service
- Client still connects to Eagle on port 40032
## Implementation Phases
### Phase 1: Go Auth Service with Eagle Proxy (Zero Client Changes)
1. **Create Go service structure**
```
src/main/go/net/eagle0/authservice/
├── main.go # Entry point, starts gRPC + HTTP servers
├── oauth.go # OAuth state management, provider configs
├── jwt.go # JWT creation (copy logic from Scala)
├── handlers.go # gRPC handlers for Auth service
├── http_callback.go # HTTP handler for OAuth callback
└── BUILD.bazel
```
2. **Internal gRPC proto for Eagle UserService**
```protobuf
// src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto
service InternalUserService {
rpc GetOrCreateUser(GetOrCreateUserRequest) returns (GetOrCreateUserResponse);
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}
message GetOrCreateUserRequest {
string provider = 1; // "discord" or "google"
string provider_user_id = 2;
string email = 3;
string avatar_url = 4;
}
message GetOrCreateUserResponse {
string user_id = 1;
string display_name = 2;
string avatar_url = 3;
bool is_admin = 4;
bool is_new_user = 5;
}
```
3. **Eagle: Expose InternalUserService**
- New `InternalUserServiceImpl.scala` wrapping UserService
- Bind to same port, different service name (internal only)
4. **Eagle: Proxy Auth RPCs to Go**
- AuthServiceImpl delegates GetOAuthUrl, CheckOAuthStatus, RefreshToken to Go service
- SetDisplayName, GetCurrentUser, Logout stay in Eagle
5. **Share RSA keys via volume mount**
- Go service reads same key files as Eagle
- Both can create valid JWTs
- Eagle continues to validate JWTs
6. **Docker/Container setup**
- New Dockerfile for Go auth service
- docker-compose or Kubernetes config for both containers
- Shared volume for /etc/eagle0/keys/
- Internal network for container-to-container gRPC
### Phase 2: Client Direct to Go Service (Future)
1. **Update Unity client**
- Connect to Go Auth service directly for OAuth RPCs
- Keep connecting to Eagle for game RPCs
2. **Remove Eagle proxy code**
- Delete AuthServiceImpl OAuth delegation
- AuthServiceImpl only handles SetDisplayName, GetCurrentUser, Logout
### Phase 3: Move JWT Validation to Go (Optional Future)
1. **Go service validates JWTs**
- Add ValidateToken RPC or use shared middleware pattern
2. **Eagle calls Go for validation**
- AuthorizationInterceptor calls Go to validate tokens
- OR: Use stateless validation (both share public key)
## Files to Create
### Go Service
- `src/main/go/net/eagle0/authservice/main.go`
- `src/main/go/net/eagle0/authservice/oauth.go`
- `src/main/go/net/eagle0/authservice/jwt.go`
- `src/main/go/net/eagle0/authservice/handlers.go`
- `src/main/go/net/eagle0/authservice/http_callback.go`
- `src/main/go/net/eagle0/authservice/BUILD.bazel`
### Protos
- `src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto`
### Scala
- `src/main/scala/net/eagle0/eagle/service/InternalUserServiceImpl.scala`
### Docker/Deployment
- `ci/auth_service.Dockerfile`
- Update `docker-compose.yml` (or equivalent)
## Files to Modify
### Scala (Phase 1)
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - Proxy OAuth RPCs to Go
- `src/main/scala/net/eagle0/eagle/Main.scala` - Start internal user service, add auth-service-url flag
### Scala (Phase 2 - Removal)
- Delete `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala`
- Delete `src/main/scala/net/eagle0/eagle/service/OAuthHttpHandler.scala`
- Simplify `src/main/scala/net/eagle0/eagle/Main.scala` - Remove HTTP server
### Unity (Phase 2)
- `Assets/Auth/OAuthManager.cs` - Point OAuth RPCs to Go service port
- `Assets/EagleConnection.cs` - Add second channel for auth service
## Key Implementation Details
### State Management in Go
```go
type OAuthState struct {
Provider string
CreatedAt time.Time
}
type OAuthResult struct {
Success bool
UserInfo *ProviderUserInfo
Provider string
Error string
}
var pendingOAuth = sync.Map{} // state -> OAuthState
var completedOAuth = sync.Map{} // state -> OAuthResult
const stateExpiration = 10 * time.Minute
// Background goroutine cleans expired states every minute
func cleanupExpiredStates() {
ticker := time.NewTicker(1 * time.Minute)
for range ticker.C {
cutoff := time.Now().Add(-stateExpiration)
pendingOAuth.Range(func(key, value any) bool {
if value.(OAuthState).CreatedAt.Before(cutoff) {
pendingOAuth.Delete(key)
}
return true
})
// Similar for completedOAuth
}
}
```
### JWT Creation in Go
```go
import "github.com/golang-jwt/jwt/v5"
type EagleClaims struct {
jwt.RegisteredClaims
UserId string `json:"userId"`
DisplayName string `json:"displayName"`
IsAdmin bool `json:"isAdmin"`
}
func CreateAccessToken(userId, displayName string, isAdmin bool) (string, error) {
claims := EagleClaims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
UserId: userId,
DisplayName: displayName,
IsAdmin: isAdmin,
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
return token.SignedString(privateKey)
}
```
### OAuth Provider Configs
- Read from environment variables (same as current OAuthConfig.scala)
- DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET
- GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
- OAUTH_CALLBACK_URL (e.g., https://eagle0.shardok.games/oauth/callback)
### Container Networking
```yaml
# docker-compose.yml example
services:
eagle0-auth:
build:
context: .
dockerfile: ci/auth_service.Dockerfile
ports:
- "40033:40033" # gRPC
- "8080:8080" # HTTP callback
volumes:
- ./keys:/etc/eagle0/keys:ro
environment:
- DISCORD_CLIENT_ID
- DISCORD_CLIENT_SECRET
- GOOGLE_CLIENT_ID
- GOOGLE_CLIENT_SECRET
- EAGLE_INTERNAL_URL=eagle0-server:40034
eagle0-server:
build:
context: .
dockerfile: ci/eagle_run.Dockerfile
ports:
- "40032:40032" # Public gRPC
expose:
- "40034" # Internal gRPC (container-to-container only)
volumes:
- ./keys:/etc/eagle0/keys:ro
- ./data:/var/lib/eagle0
environment:
- AUTH_SERVICE_URL=eagle0-auth:40033
```
## Deployment
### Development
```bash
# Terminal 1: Go Auth Service
bazel run //src/main/go/net/eagle0/authservice:authservice -- \
--grpc-port=40033 \
--http-port=8080 \
--eagle-internal-url=localhost:40034
# Terminal 2: Eagle Server
bazel run //src/main/scala/net/eagle0/eagle:eagle_server -- \
--eagle-grpc-port=40032 \
--internal-grpc-port=40034 \
--auth-service-url=localhost:40033
```
### Production
- Both containers on same droplet via docker-compose
- Shared volume for RSA keys at /etc/eagle0/keys/
- Internal Docker network for container-to-container communication
- External access: 40032 (Eagle gRPC), 8080 (OAuth HTTP callback)
## Testing Strategy
1. **Unit tests for Go service**
- OAuth state management (expiration, cleanup)
- JWT creation matches Scala output (test with same keys)
- HTTP callback parsing
2. **Integration tests**
- Go service ↔ Eagle internal gRPC
- Full OAuth flow with mock provider
3. **Existing tests continue to pass**
- All Scala tests (JWT validation, user service)
4. **End-to-end test**
- Spin up both containers
- Run OAuth flow through proxy
## Risks and Mitigations
| Risk | Mitigation |
|------|------------|
| Key file permissions | Shared volume with read-only mount |
| State loss on Go restart | Document this (same as current Scala behavior); consider Redis later |
| Clock skew affecting JWT | Both on same machine |
| OAuth callback race | HTTP callback completes before gRPC poll |
| Container networking | Use docker-compose for reliable internal DNS |
| Proxy adds latency | Minimal (same machine), remove in Phase 2 |
## Estimated Scope
- **Phase 1**: ~500-700 lines Go, ~100 lines Scala changes, ~50 lines Docker config
- **Phase 2**: ~50 lines Unity, deletion of ~300 lines Scala
- **Phase 3**: Optional, separate decision
## Alternative Considered: Move Everything to Go
Could move UserService to Go as well, but:
- UserService is tightly integrated with game persistence
- Would require duplicating persistence layer
- Not worth the complexity for now
Keep UserService in Eagle, expose via internal gRPC.
## Open Questions
1. **HTTP callback routing**: Does the OAuth callback URL need to change, or can we route traffic from the existing URL to the new Go service?
2. **Health checks**: Should we add health check endpoints for container orchestration?
3. **Logging**: Should Go service log to same format/destination as Eagle?
-189
View File
@@ -1,189 +0,0 @@
# Discord + Google OAuth Implementation Plan
## Overview
Replace HTTP Basic Auth with OAuth 2.0 (Discord + Google) for Eagle0. Users authenticate via system browser, receive JWT tokens, and choose their own display names.
## Architecture
```
Unity Client Eagle Server
| |
| 1. Click "Login with Discord/Google" |
| -------------------------------------------------> |
| GetOAuthUrl(provider) -> auth_url + state |
| |
| 2. Open system browser -> OAuth consent |
| 3. User authenticates with provider |
| 4. Redirect to eagle0://auth/callback?code=xxx |
| |
| 5. ExchangeCode(code, state) |
| -------------------------------------------------> |
| Exchange code with provider |
| Fetch user info (id, email, avatar) |
| Create/update user record |
| Issue JWT + refresh token |
| <------------------------------------------------- |
| (jwt, refresh_token, user_info, is_new_user) |
| |
| 6. [If new user] SetDisplayName(name) |
| -------------------------------------------------> |
| |
| 7. Subsequent gRPC calls |
| Authorization: Bearer <jwt> |
| -------------------------------------------------> |
```
## Key Design Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| OAuth flow | System browser + deep link | Secure, supports password managers |
| Code exchange | Eagle server directly | No separate auth service needed |
| JWT signing | RS256 (asymmetric) | Future flexibility for token verification |
| User storage | Protobuf file via Persister | Consistent with existing patterns |
| Token expiry | 7-day access, 30-day refresh | Balance security and gaming UX |
## Implementation Phases
### Phase 1: Proto Definitions & Infrastructure
**New files:**
- `src/main/protobuf/net/eagle0/eagle/api/auth.proto` - Auth API messages
- `src/main/protobuf/net/eagle0/eagle/internal/user.proto` - User storage schema
**Key proto messages:**
```protobuf
// API
GetOAuthUrlRequest/Response // Get OAuth URL to open in browser
ExchangeCodeRequest/Response // Exchange auth code for JWT
SetDisplayNameRequest/Response // Set user's display name
RefreshTokenRequest/Response // Refresh expired access token
// Internal storage
User // user_id, display_name, oauth_identities
UserDatabase // All users + indexes for lookup
```
### Phase 2: Eagle Server Auth Services
**New Scala files:**
- `src/main/scala/net/eagle0/eagle/auth/OAuthConfig.scala` - Discord/Google config from env vars
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - JWT creation/validation (RS256)
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD, display name validation
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth code exchange
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC service implementation
**Modify:**
- `src/main/scala/net/eagle0/eagle/service/AuthorizationInterceptor.scala`
- Replace Basic Auth parsing with JWT validation
- Skip auth for public endpoints (GetOAuthUrl, ExchangeCode, RefreshToken)
- `src/main/scala/net/eagle0/eagle/service/AuthorizationUtils.scala`
- Change context keys from `userName` to `userId` + `displayName`
- `src/main/scala/net/eagle0/eagle/service/Main.scala`
- Wire up new auth services and JWT key loading
### Phase 3: Unity Client OAuth Flow
**New C# files:**
- `Assets/Auth/OAuthManager.cs` - OAuth flow + deep link handling
- `Assets/Auth/TokenStorage.cs` - Secure token persistence
- `Assets/Auth/AuthClient.cs` - gRPC client for auth service
**Modify:**
- `Assets/EagleConnection.cs`
- Replace `AuthInterceptor` (Basic Auth) with `JwtAuthInterceptor` (Bearer token)
- `Assets/ConnectionHandler/ConnectionHandler.cs`
- Replace username/password UI with Discord/Google login buttons
- Add display name setup flow for new users
### Phase 4: Platform Configuration
**Deep link registration:**
- iOS: Add `eagle0://` to CFBundleURLSchemes in Info.plist
- Android: Add intent-filter for `eagle0://auth` in AndroidManifest.xml
- Desktop: Register URL scheme (Windows registry / macOS plist)
**OAuth provider setup:**
1. Discord Developer Portal: Create app, add redirect URI `eagle0://auth/callback`
2. Google Cloud Console: Create OAuth client, add redirect URI
**Environment variables (server):**
```
DISCORD_CLIENT_ID
DISCORD_CLIENT_SECRET
GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET
JWT_PRIVATE_KEY_PATH
JWT_PUBLIC_KEY_PATH
```
### Phase 5: Testing
**Unit tests:**
- `JwtServiceSpec.scala` - Token creation/validation
- `UserServiceSpec.scala` - Display name validation, uniqueness
- `OAuthServiceSpec.scala` - OAuth flow with mocked providers
**Integration tests:**
- Full OAuth flow with mock provider
- JWT validation in AuthorizationInterceptor
- gRPC calls with valid/invalid tokens
**Manual testing:**
- [ ] Discord login (Windows, macOS)
- [ ] Google login (Windows, macOS)
- [ ] Deep link callback works
- [ ] Display name validation
- [ ] Session persistence across restarts
- [ ] Token refresh
## Files Summary
### Create
| File | Purpose |
|------|---------|
| `src/main/protobuf/net/eagle0/eagle/api/auth.proto` | Auth API definitions |
| `src/main/protobuf/net/eagle0/eagle/internal/user.proto` | User storage schema |
| `src/main/scala/net/eagle0/eagle/auth/OAuthConfig.scala` | Provider config |
| `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` | JWT handling |
| `src/main/scala/net/eagle0/eagle/auth/UserService.scala` | User management |
| `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` | OAuth flow |
| `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` | gRPC service |
| `Assets/Auth/OAuthManager.cs` | Unity OAuth manager |
| `Assets/Auth/TokenStorage.cs` | Token storage |
| `Assets/Auth/AuthClient.cs` | Auth gRPC client |
### Modify
| File | Changes |
|------|---------|
| `AuthorizationInterceptor.scala` | Basic Auth -> JWT validation |
| `AuthorizationUtils.scala` | userName -> userId + displayName |
| `Main.scala` | Wire auth services |
| `EagleConnection.cs` | AuthInterceptor -> JwtAuthInterceptor |
| `ConnectionHandler.cs` | Login UI -> OAuth buttons + display name |
### Delete
- nginx htpasswd configuration (no longer needed)
## Security Considerations
1. **State parameter** - CSRF protection in OAuth flow
2. **PKCE** - Consider adding for mobile (enhancement)
3. **Secure storage** - Use Keychain (iOS) / Keystore (Android) for tokens
4. **Token refresh** - 7-day access tokens with 30-day refresh
5. **Rate limiting** - Limit login attempts per IP
## Dependencies to Add
**Scala (MODULE.bazel):**
- JWT library (e.g., `jwt-scala` or `nimbus-jose-jwt`)
- HTTP client (e.g., `sttp` for OAuth requests)
**Unity:**
- Deep linking is built-in (Unity 2021+)
- No additional packages required
## Rollback Plan
Keep Basic Auth code in a feature branch. Both auth methods can coexist during transition via feature flag if needed.
-350
View File
@@ -1,350 +0,0 @@
# OAuth Implementation: Next Steps and Design
## Executive Summary
The OAuth implementation is functional but has several gaps that need addressing before it's production-ready. This document outlines the known issues, proposes a comprehensive user identity model, and provides a prioritized implementation plan.
## Current State (Updated January 2026)
### What Works ✅
- Discord OAuth flow (server-mediated polling)
- Google OAuth flow
- JWT token generation and validation
- User creation and display name setting
- Auto-login with stored tokens
- Basic game creation and play with OAuth users
- Headshot fetching via public CDN (no auth required)
- Logout button in lobby (preserves tokens for quick reconnect)
- Environment (prod/qa) and user display in lobby
- Game identity with userName = displayName (PR #4964 merged)
### Known Issues
#### 1. Game Identity Model Fragility (Deferred)
**Status**: Accepted for now. PR #4964 merged with `userName = displayName`.
**Current behavior**:
- Games store `userNameToFactionId: Map[String, Int]`
- For JWT users, this maps displayName → factionId
- displayName is technically mutable (users could change it)
- No migration path when displayName changes
**Why this is acceptable**:
1. We don't currently have a "change display name" feature
2. The alternative (using userId) requires more extensive changes
3. Can migrate to userId-based identity later if needed
#### 2. In-Game Headshot Fetching ✅ FIXED
**Solution**: Made the `eagle0-headshots` S3 bucket public and enabled CDN.
- Client now fetches directly from `https://eagle0-headshots.sfo3.cdn.digitaloceanspaces.com/`
- No authentication required
- Works for both OAuth and Basic Auth users
- Simpler architecture, no dependency on home Mac server
#### 3. Logout from Lobby ✅ FIXED
**Solution**: Added logout button to lobby UI (PR #4967).
- Button disconnects from server and returns to connection screen
- Intentionally does NOT clear OAuth tokens
- Allows quick reconnect with same account without full OAuth flow
#### 4. Display Name Uniqueness Not Enforced (Medium) - OPEN
**Problem**: User was able to set displayName "nolen" when that name was already taken.
**Root cause**: Unknown - needs investigation. Either:
- The uniqueness check is buggy
- The displayNameIndex wasn't populated correctly during user creation
- Race condition during concurrent registrations
#### 5. Admin Server Crashes ✅ FIXED
**Solution**: PR #4964 sets `userName = displayName` for JWT users.
#### 6. Intermittent "Expired" Errors During Login (Medium) - INVESTIGATING
**Problem**: Users occasionally get "OAuth session expired" errors even when server logs show the callback succeeded.
**Status**: Added diagnostic logging in PR #4974 to trace:
- State creation in `getAuthUrl`
- State lookup in `handleCallback`
- Result lookup in `checkStatus`
**Possible causes**:
- State mismatch between client and server
- Race condition in polling
- Cleanup running at wrong time
#### 7. Token Expiry Field Bug ✅ FIXED
**Problem**: `CheckOAuthStatusResponse.expiresAt` was returning refresh token expiry (30 days) instead of access token expiry (7 days).
**Solution**: Fixed in PR #4974 to calculate correct access token expiry.
---
## Proposed User Identity Model
### Design Principles
1. **Stable Internal Identity**: `userId` (UUID) is the only key used for persistent associations
2. **Display Name is Cosmetic**: Can change without breaking game associations
3. **Backwards Compatibility**: Basic Auth continues to work for local development
4. **Multi-Provider Support**: Users can link Discord, Google, and future providers
5. **Avatar Flexibility**: Use OAuth avatar by default, support custom uploads later
### Data Model
```
User {
userId: String (UUID) // Primary key, immutable, used for all internal references
displayName: String // Unique, user-visible, mutable with migration
displayNameLower: String // Case-insensitive uniqueness
email: String // Primary email for account recovery/linking
avatarUrl: String // Current avatar URL
avatarData: bytes // Cached avatar for offline/fast access (future)
oauthIdentities: [OAuthIdentity]
createdAt: Timestamp
lastLoginAt: Timestamp
isAdmin: Boolean
}
OAuthIdentity {
provider: String // "discord", "google", etc.
providerUserId: String // Provider's user ID
providerEmail: String // Email from this provider
avatarUrl: String // Avatar from this provider
linkedAt: Timestamp
}
```
### Identity Resolution Strategy
The key question: **What should `AuthorizationUtils.userName` return?**
#### Option A: userName = displayName (Current PR #4964)
- **Pro**: Human-readable in logs, game saves, debugging
- **Con**: Breaks if displayName changes
- **Migration**: None needed now, complex later
#### Option B: userName = userId (Recommended)
- **Pro**: Stable identity, displayName changes are safe
- **Con**: UUIDs in logs are ugly, need display name lookup for UI
- **Migration**: Cleaner long-term, but breaking change for any existing OAuth games
#### Option C: Hybrid with Migration Support
- **userName** = userId for new games
- **Legacy lookup** for old games by displayName
- **Display layer** resolves userId → displayName for UI
**Recommendation**: Option B with a display name resolution layer. The ugliness in logs is acceptable for the stability it provides. Implement a `UserService.resolveDisplayName(identifier: String): String` that returns displayName for UUIDs or the identifier itself for legacy usernames.
### Account Linking Strategy
#### Automatic Linking (Future)
When a user logs in with a new OAuth provider:
1. Check if the provider email matches an existing user's email
2. If match found, prompt: "An account exists with this email. Link accounts?"
3. If confirmed, add new OAuthIdentity to existing user
4. If declined, create separate account (different email required)
#### Manual Linking (MVP)
1. User logs in with primary account
2. User goes to Settings → Linked Accounts
3. User clicks "Link Discord" or "Link Google"
4. OAuth flow adds new identity to current user
### Avatar/Headshot Strategy
#### Phase 1: OAuth Avatars (MVP)
- Store `avatarUrl` from OAuth provider during login
- Server proxies avatar requests to avoid CORS issues
- Cache avatars locally with TTL
#### Phase 2: Avatar Caching
- Download avatar to local storage on login
- Serve from local storage for reliability
- Refresh periodically or on login
#### Phase 3: Custom Avatars (Future)
- Allow users to upload custom avatar
- Store in S3/DO Spaces
- Custom avatar overrides OAuth avatar
---
## Implementation Plan
### Phase 1: Stabilization ✅ COMPLETE
#### 1.1 Fix Display Name Uniqueness Bug - OPEN
- [ ] Investigate why "nolen" was allowed when it existed
- [ ] Add logging to `setDisplayName` to trace the issue
- [ ] Ensure `displayNameIndex` is correctly maintained
- [ ] Add unit tests for uniqueness enforcement
#### 1.2 Add Logout Button to Lobby ✅ DONE
- [x] Add "Logout" button to lobby UI
- [x] Disconnect from server
- [x] Navigate to connection screen
- [x] Preserve OAuth tokens for quick reconnect (intentional change from original plan)
#### 1.3 Merge PR #4964 (userName = displayName) ✅ DONE
- [x] Merged - games work with OAuth users
- [x] Documented limitation (games break if displayName changes)
#### 1.4 Fix Headshot Fetching ✅ DONE
- [x] Made eagle0-headshots bucket public
- [x] Client fetches directly from CDN
- [x] No authentication required
#### 1.5 Add Lobby Status Display ✅ DONE
- [x] Show environment (prod/qa) in lobby
- [x] Show current user in lobby (OAuth displayName or classic username)
### Phase 2: Remaining Work (Priority Order)
#### 2.1 Diagnose Intermittent "Expired" Errors - IN PROGRESS
- [x] Add diagnostic logging (PR #4974)
- [ ] Deploy and reproduce the issue
- [ ] Analyze logs to identify root cause
- [ ] Implement fix based on findings
#### 2.2 Fix Display Name Uniqueness
- [ ] Investigate UserService.setDisplayName logic
- [ ] Check displayNameIndex population
- [ ] Add logging to trace the issue
- [ ] Fix the bug and add tests
#### 2.3 Wire Up Lobby UI in Unity
- [ ] Connect `lobbyEnvironmentText` to TextMeshProUGUI in scene
- [ ] Connect `lobbyUserText` to TextMeshProUGUI in scene
#### 2.4 Implement Token Refresh During Gameplay
- [ ] Implement `RefreshToken` RPC on server (currently throws UNIMPLEMENTED)
- [ ] Store refresh tokens server-side for validation
- [ ] Add proactive refresh in client before token expires
- [ ] Handle refresh during reconnection attempts
### Phase 3: Nice-to-Haves (Future)
#### 3.1 Proactive Token Refresh
- [ ] Monitor token expiry in client
- [ ] Refresh automatically when < 5 minutes remaining
- [ ] Update TokenStorage with new access token
#### 3.2 Better Error Messages
- [ ] Distinguish between network errors and auth errors
- [ ] Show user-friendly messages for OAuth failures
- [ ] Add retry suggestions
#### 3.3 Session Persistence Across Server Restarts
- [ ] Move pendingOAuth from in-memory TrieMap to Redis/database
- [ ] Move completedOAuth to Redis with TTL
- [ ] Server can restart without breaking in-flight OAuth flows
#### 3.4 Migrate to userId-based Game Identity (Deferred)
- [ ] Change `AuthorizationUtils.userName` to return `userId` for JWT users
- [ ] Add `UserService.resolveDisplayName(id: String): String` for UI display
- [ ] Update game UI to resolve userIds to displayNames
- [ ] Existing Basic Auth games continue to work (userName is literal)
#### 3.5 Display Name Change Support (Requires 3.4)
- [ ] Add `ChangeDisplayName` RPC
- [ ] Validate new name is unique
- [ ] Update user record
- [ ] No game migration needed (games use userId)
### Phase 3: Multi-Provider Support (Future)
#### 3.1 Account Linking UI
- [ ] Add Settings page with "Linked Accounts" section
- [ ] Show currently linked providers
- [ ] "Link Another Account" button triggers OAuth flow
- [ ] `LinkOAuthProvider` RPC adds identity to current user
#### 3.2 Login Provider Selection
- [ ] If user has multiple providers, any can be used to login
- [ ] All resolve to same userId
- [ ] Session shows which provider was used
#### 3.3 Account Merging (Complex)
- [ ] Handle case where user created separate accounts
- [ ] Merge game history, stats, etc.
- [ ] Delete duplicate user record
- [ ] This is complex - may defer or not implement
### Phase 4: Enhanced Avatars (Future)
#### 4.1 Avatar Caching
- [ ] Download avatars to S3/DO Spaces on login
- [ ] Serve from our CDN
- [ ] Refresh on login if changed
#### 4.2 Custom Avatar Upload
- [ ] Upload endpoint with size/format validation
- [ ] Store in S3/DO Spaces
- [ ] Custom avatar overrides OAuth avatar
---
## Technical Debt to Address
1. **Context Propagation in Futures**: PR #4960 fixed `setDisplayName` and `getCurrentUser`, but audit all `Future` blocks that access `AuthorizationUtils`
2. **Dual Auth Support**: The system supports both Basic Auth and JWT. Consider:
- Should Basic Auth be deprecated for production?
- Should it remain for local development only?
- How do Basic Auth users interact with OAuth users in the same game?
3. **Token Refresh**: `RefreshToken` RPC throws UNIMPLEMENTED. Need to:
- Implement refresh token storage and validation
- Handle token refresh in client
- Consider refresh token rotation for security
4. **Session Management**: No server-side session tracking. Consider:
- Track active sessions per user
- Allow "logout all devices"
- Detect concurrent logins
---
## Open Questions
1. **What happens when a Basic Auth user and OAuth user have the same name?**
- Currently possible - Basic Auth doesn't check UserService
- Could cause confusion in games
- Solution: Require OAuth for multiplayer? Or namespace Basic Auth names?
2. **Should displayName changes be allowed?**
- With userId-based identity, it's safe
- But could cause confusion ("who is this new player?")
- Consider: rate limit changes, show "formerly known as" temporarily
3. **How to handle OAuth provider account deletion?**
- User deletes their Discord account
- Their Eagle0 account still exists
- They can't login unless they linked another provider
- Solution: Encourage linking multiple providers, or add email/password fallback
4. **Admin impersonation with OAuth**
- Currently works via X-Impersonate-User header
- Should this use userId or displayName?
- Probably userId for stability
---
## Appendix: File Locations
### Server (Scala)
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - Token generation/validation
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth flow
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC Auth service
- `src/main/scala/net/eagle0/eagle/service/AuthorizationInterceptor.scala` - Auth middleware
- `src/main/scala/net/eagle0/eagle/service/AuthorizationUtils.scala` - Context accessors
### Client (C#)
- `Assets/Auth/AuthClient.cs` - gRPC client for Auth service
- `Assets/Auth/OAuthManager.cs` - OAuth flow orchestration
- `Assets/Auth/TokenStorage.cs` - Persistent token storage
- `Assets/Auth/JwtAuthInterceptor.cs` - Attaches JWT to requests
### Protos
- `src/main/protobuf/net/eagle0/eagle/api/auth.proto` - Auth service definition
- `src/main/protobuf/net/eagle0/eagle/internal/user/user.proto` - User data model
File diff suppressed because it is too large Load Diff
-216
View File
@@ -1,216 +0,0 @@
# Shardok Latency Hiding Strategies
## Problem Statement
With Shardok running on Hetzner (Helsinki) and Eagle on DigitalOcean (US), the round-trip latency for human commands is ~200-400ms:
```
Human posts command:
Unity → Eagle (DO) → Shardok (Hetzner) → Eagle (DO) → Unity
~10ms ~100ms ~100ms ~10ms
Total: ~220ms round-trip
```
This latency is acceptable for AI turns (users watch animations anyway), but creates noticeable lag when humans post commands.
---
## Strategy 1: Client-Side Animation Masking
### Concept
Start animations immediately when the user clicks, before server confirmation arrives. The animation duration masks the network latency.
### Implementation by Command Type
**Movement Commands** (deterministic):
- Client knows the destination hex and movement path
- Start movement animation immediately on click
- Server confirms the move (should always match)
- If server rejects (invalid state), snap unit back to origin
**Attack Commands** (RNG-dependent):
- Show attack animation immediately (unit swings sword, fires arrow)
- Wait for server to return dice roll result
- Show damage numbers / hit effects when server responds
- Animation typically takes 300-500ms, masking most of the latency
**End Turn**:
- Latency not noticeable (user expects transition delay)
### Unity Implementation Sketch
```csharp
// In CommandHandler.cs
public void OnCommandSelected(Command command) {
// Start animation immediately
if (command.Type == CommandType.Move) {
unitController.StartMoveAnimation(command.TargetHex);
} else if (command.Type == CommandType.Attack) {
unitController.StartAttackAnimation(command.TargetUnit);
}
// Send to server in parallel
connection.SendCommand(command, (response) => {
if (response.Success) {
// Animation continues, apply result
ApplyCommandResult(response);
} else {
// Rollback animation
unitController.CancelAnimation();
ShowError(response.ErrorMessage);
}
});
}
```
### Pros
- Simple implementation
- No server-side changes
- Works with existing architecture
### Cons
- Doesn't eliminate latency for attacks with RNG (must wait for dice roll)
- Rollback needed if server rejects command (rare but possible)
### Estimated Improvement
- Movement: ~200ms latency hidden (feels instant)
- Attacks: ~100-200ms hidden by animation, ~100ms visible wait for dice result
---
## Strategy 2: Split Shardok Architecture
### Concept
Run two Shardok instances:
- **Shardok-Primary (DigitalOcean)**: Handles command processing, source of truth
- **Shardok-AI (Hetzner)**: AI computation only
Human commands go to the nearby Primary for low latency. AI computation uses the powerful Hetzner instance.
### Architecture
```
Human commands (low latency ~20ms)
Unity ←→ Eagle ←→ Shardok-Primary (DigitalOcean)
↓ state sync (when AI turn starts)
Shardok-AI (Hetzner)
↑ AI command response
```
### How It Works
**Human Turn:**
1. Human posts command → Eagle → Shardok-Primary (DO)
2. Primary processes command immediately (~10ms local)
3. Primary streams result to client via Eagle (~10ms)
4. **Total latency: ~20ms** (vs ~220ms current)
**AI Turn:**
1. When AI's turn starts, Primary sends game state snapshot to Hetzner
2. Shardok-AI computes best command using full CPU power
3. Shardok-AI returns command index to Primary
4. Primary executes command locally and streams to client
5. Repeat until AI turn ends
### Protocol Changes
```protobuf
// New service for AI-only computation
service ShardokAIService {
// Send game state, receive AI's chosen command
rpc GetAICommand(AICommandRequest) returns (AICommandResponse);
}
message AICommandRequest {
bytes game_state = 1; // Serialized game state
int32 player_id = 2; // Which AI player
repeated bytes available_commands = 3; // Available command descriptors
}
message AICommandResponse {
int32 command_index = 1; // Index into available_commands
int32 search_depth = 2; // For debugging
double best_score = 3; // For debugging
}
```
### Shardok-Primary Requirements
Shardok-Primary on DigitalOcean needs to:
- Process all commands (human and AI)
- Maintain authoritative game state
- Serialize/deserialize game state for AI requests
- Run on minimal CPU (command processing is fast)
This is essentially the current Shardok, but without running the AI search.
### Shardok-AI Requirements
Shardok-AI on Hetzner needs to:
- Receive game state snapshots
- Run AI evaluation (IterativeDeepeningAI or MCTS)
- Return best command index
- No persistent state (stateless worker)
### AI Turn Latency
Each AI command has ~200ms network latency. This is acceptable because:
1. User is watching animations anyway
2. Natural pacing lets user observe AI decisions
3. AI computation is fast on Hetzner's 16 cores
For a typical AI turn with 5 commands: 5 × 200ms = 1 second network overhead, plus AI thinking time. With animations, this feels natural.
### Implementation Phases
**Phase 1: Add Shardok-Primary (minimal)**
- Deploy existing Shardok container to DigitalOcean
- Configure Eagle to use local Shardok for all commands
- Human latency immediately improves
**Phase 2: Add AI offload**
- Implement `ShardokAIService` RPC
- Shardok-Primary calls Hetzner for AI commands
- Shardok-AI processes requests statelessly
**Phase 3: Optimize**
- Batch multiple AI actions if possible
- Pre-warm Shardok-AI connection
- Add fallback if Hetzner unavailable
### Pros
- Human command latency drops from ~220ms to ~20ms
- AI still gets Hetzner's CPU power
- Clear separation of concerns
- Shardok-Primary can fall back to local AI if Hetzner unavailable
### Cons
- Two Shardok instances to maintain
- State serialization overhead for AI requests
- Each AI action has network round-trip (acceptable with animations)
---
## Comparison
| Approach | Human Latency | AI Throughput | Complexity | Changes Required |
|----------|---------------|---------------|------------|------------------|
| Current | ~220ms | High | - | - |
| Animation masking | ~220ms (perceived ~50ms) | High | Low | Unity only |
| Split architecture | ~20ms | High | Medium | New RPC, two deployments |
---
## Recommendation
**Phase 1 (now)**: Implement animation masking in Unity client
- Quick win, no server changes
- Improves perceived latency significantly for movement
- Attacks still show dice animation while waiting
**Phase 2 (future)**: Split architecture if animation masking insufficient
- Only needed if users complain about attack latency
- More complex but provides true low latency
- Natural evolution of current architecture
Binary file not shown.
-3
View File
@@ -9,9 +9,6 @@ require (
github.com/aws/aws-sdk-go-v2/config v1.28.10
github.com/aws/aws-sdk-go-v2/credentials v1.17.51
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
google.golang.org/grpc v1.68.0
google.golang.org/protobuf v1.36.3
)
-6
View File
@@ -34,18 +34,12 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.33.6 h1:VwhTrsTuVn52an4mXx29PqRzs2Dv
github.com/aws/aws-sdk-go-v2/service/sts v1.33.6/go.mod h1:+8h7PZb3yY5ftmVLD7ocEoE98hdc8PoKS0H3wfx1dlc=
github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0=
google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA=
google.golang.org/protobuf v1.26.0-rc.1 h1:7QnIQpGRHE5RnLKnESfDoxm2dTapTZua5a0kS0A+VXQ=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
+12 -185
View File
@@ -1,10 +1,9 @@
{
"__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL",
"__INPUT_ARTIFACTS_HASH": -2049857450,
"__RESOLVED_ARTIFACTS_HASH": -1728186926,
"__INPUT_ARTIFACTS_HASH": 571423113,
"__RESOLVED_ARTIFACTS_HASH": 438039003,
"conflict_resolution": {
"com.google.guava:failureaccess:1.0.1": "com.google.guava:failureaccess:1.0.2",
"com.squareup.okio:okio:2.10.0": "com.squareup.okio:okio:3.6.0",
"io.netty:netty-buffer:4.1.110.Final": "io.netty:netty-buffer:4.1.112.Final",
"io.netty:netty-codec-http2:4.1.110.Final": "io.netty:netty-codec-http2:4.1.112.Final",
"io.netty:netty-codec-http:4.1.110.Final": "io.netty:netty-codec-http:4.1.112.Final",
@@ -48,12 +47,6 @@
},
"version": "2.12.7"
},
"com.github.stephenc.jcip:jcip-annotations": {
"shasums": {
"jar": "4fccff8382aafc589962c4edb262f6aa595e34f1e11e61057d1c6a96e8fc7323"
},
"version": "1.0-1"
},
"com.google.android:annotations": {
"shasums": {
"jar": "ba734e1e84c09d615af6a09d33034b4f0442f8772dec120efb376d86a565ae15"
@@ -162,24 +155,6 @@
},
"version": "1.4.2"
},
"com.nimbusds:nimbus-jose-jwt": {
"shasums": {
"jar": "12ae4a3a260095d7aeba2adea7ae396e8b9570db8b7b409e09a824c219cc0444"
},
"version": "9.37.3"
},
"com.squareup.okhttp3:okhttp": {
"shasums": {
"jar": "b1050081b14bb7a3a7e55a4d3ef01b5dcfabc453b4573a4fc019767191d5f4e0"
},
"version": "4.12.0"
},
"com.squareup.okhttp3:okhttp-sse": {
"shasums": {
"jar": "bff4fbcaef7aac2d910d4ff46dafaa4e6d15da127df6bac97216da46943a7d4c"
},
"version": "4.12.0"
},
"com.squareup.okhttp:okhttp": {
"shasums": {
"jar": "88ac9fd1bb51f82bcc664cc1eb9c225c90dc4389d660231b4cc737bebfe7d0aa"
@@ -188,15 +163,9 @@
},
"com.squareup.okio:okio": {
"shasums": {
"jar": "8e63292e5c53bb93c4a6b0c213e79f15990fed250c1340f1c343880e1c9c39b5"
"jar": "a27f091d34aa452e37227e2cfa85809f29012a8ef2501a9b5a125a978e4fcbc1"
},
"version": "3.6.0"
},
"com.squareup.okio:okio-jvm": {
"shasums": {
"jar": "67543f0736fc422ae927ed0e504b98bc5e269fda0d3500579337cb713da28412"
},
"version": "3.6.0"
"version": "2.10.0"
},
"com.thesamet.scalapb:compilerplugin_3": {
"shasums": {
@@ -413,12 +382,6 @@
},
"version": "0.27.0"
},
"io.sentry:sentry": {
"shasums": {
"jar": "740a118182fc089d307830f4e508372e01ad94639b00b4e1b1d83762298a5f35"
},
"version": "7.19.0"
},
"javax.activation:javax.activation-api": {
"shasums": {
"jar": "43fdef0b5b6ceb31b0424b208b930c74ab58fac2ceeb7b3f6fd3aeb8b5ca4393"
@@ -481,27 +444,15 @@
},
"org.jetbrains.kotlin:kotlin-stdlib": {
"shasums": {
"jar": "55e989c512b80907799f854309f3bc7782c5b3d13932442d0379d5c472711504"
"jar": "b8ab1da5cdc89cb084d41e1f28f20a42bd431538642a5741c52bbfae3fa3e656"
},
"version": "1.9.10"
"version": "1.4.20"
},
"org.jetbrains.kotlin:kotlin-stdlib-common": {
"shasums": {
"jar": "cde3341ba18a2ba262b0b7cf6c55b20c90e8d434e42c9a13e6a3f770db965a88"
"jar": "a7112c9b3cefee418286c9c9372f7af992bd1e6e030691d52f60cb36dbec8320"
},
"version": "1.9.10"
},
"org.jetbrains.kotlin:kotlin-stdlib-jdk7": {
"shasums": {
"jar": "ac6361bf9ad1ed382c2103d9712c47cdec166232b4903ed596e8876b0681c9b7"
},
"version": "1.9.10"
},
"org.jetbrains.kotlin:kotlin-stdlib-jdk8": {
"shasums": {
"jar": "a4c74d94d64ce1abe53760fe0389dd941f6fc558d0dab35e47c085a11ec80f28"
},
"version": "1.9.10"
"version": "1.4.20"
},
"org.jetbrains:annotations": {
"shasums": {
@@ -828,26 +779,12 @@
"org.checkerframework:checker-qual",
"org.ow2.asm:asm"
],
"com.nimbusds:nimbus-jose-jwt": [
"com.github.stephenc.jcip:jcip-annotations"
],
"com.squareup.okhttp3:okhttp": [
"com.squareup.okio:okio",
"org.jetbrains.kotlin:kotlin-stdlib-jdk8"
],
"com.squareup.okhttp3:okhttp-sse": [
"com.squareup.okhttp3:okhttp",
"org.jetbrains.kotlin:kotlin-stdlib-jdk8"
],
"com.squareup.okhttp:okhttp": [
"com.squareup.okio:okio"
],
"com.squareup.okio:okio": [
"com.squareup.okio:okio-jvm"
],
"com.squareup.okio:okio-jvm": [
"org.jetbrains.kotlin:kotlin-stdlib-common",
"org.jetbrains.kotlin:kotlin-stdlib-jdk8"
"org.jetbrains.kotlin:kotlin-stdlib",
"org.jetbrains.kotlin:kotlin-stdlib-common"
],
"com.thesamet.scalapb:compilerplugin_3": [
"com.google.protobuf:protobuf-java",
@@ -1055,13 +992,6 @@
"org.jetbrains.kotlin:kotlin-stdlib-common",
"org.jetbrains:annotations"
],
"org.jetbrains.kotlin:kotlin-stdlib-jdk7": [
"org.jetbrains.kotlin:kotlin-stdlib"
],
"org.jetbrains.kotlin:kotlin-stdlib-jdk8": [
"org.jetbrains.kotlin:kotlin-stdlib",
"org.jetbrains.kotlin:kotlin-stdlib-jdk7"
],
"org.json4s:json4s-ast_3": [
"org.scala-lang:scala3-library_3"
],
@@ -1376,9 +1306,6 @@
"com.fasterxml.jackson.databind.type",
"com.fasterxml.jackson.databind.util"
],
"com.github.stephenc.jcip:jcip-annotations": [
"net.jcip.annotations"
],
"com.google.android:annotations": [
"android.annotation"
],
@@ -1524,61 +1451,6 @@
"com.google.truth:truth": [
"com.google.common.truth"
],
"com.nimbusds:nimbus-jose-jwt": [
"com.nimbusds.jose",
"com.nimbusds.jose.crypto",
"com.nimbusds.jose.crypto.bc",
"com.nimbusds.jose.crypto.factories",
"com.nimbusds.jose.crypto.impl",
"com.nimbusds.jose.crypto.opts",
"com.nimbusds.jose.crypto.utils",
"com.nimbusds.jose.jca",
"com.nimbusds.jose.jwk",
"com.nimbusds.jose.jwk.gen",
"com.nimbusds.jose.jwk.source",
"com.nimbusds.jose.mint",
"com.nimbusds.jose.proc",
"com.nimbusds.jose.produce",
"com.nimbusds.jose.shaded.gson",
"com.nimbusds.jose.shaded.gson.annotations",
"com.nimbusds.jose.shaded.gson.internal",
"com.nimbusds.jose.shaded.gson.internal.bind",
"com.nimbusds.jose.shaded.gson.internal.bind.util",
"com.nimbusds.jose.shaded.gson.internal.reflect",
"com.nimbusds.jose.shaded.gson.internal.sql",
"com.nimbusds.jose.shaded.gson.reflect",
"com.nimbusds.jose.shaded.gson.stream",
"com.nimbusds.jose.util",
"com.nimbusds.jose.util.cache",
"com.nimbusds.jose.util.events",
"com.nimbusds.jose.util.health",
"com.nimbusds.jwt",
"com.nimbusds.jwt.proc",
"com.nimbusds.jwt.util"
],
"com.squareup.okhttp3:okhttp": [
"okhttp3",
"okhttp3.internal",
"okhttp3.internal.authenticator",
"okhttp3.internal.cache",
"okhttp3.internal.cache2",
"okhttp3.internal.concurrent",
"okhttp3.internal.connection",
"okhttp3.internal.http",
"okhttp3.internal.http1",
"okhttp3.internal.http2",
"okhttp3.internal.io",
"okhttp3.internal.platform",
"okhttp3.internal.platform.android",
"okhttp3.internal.proxy",
"okhttp3.internal.publicsuffix",
"okhttp3.internal.tls",
"okhttp3.internal.ws"
],
"com.squareup.okhttp3:okhttp-sse": [
"okhttp3.internal.sse",
"okhttp3.sse"
],
"com.squareup.okhttp:okhttp": [
"com.squareup.okhttp",
"com.squareup.okhttp.internal",
@@ -1587,7 +1459,7 @@
"com.squareup.okhttp.internal.io",
"com.squareup.okhttp.internal.tls"
],
"com.squareup.okio:okio-jvm": [
"com.squareup.okio:okio": [
"okio",
"okio.internal"
],
@@ -1791,30 +1663,6 @@
"io.perfmark:perfmark-api": [
"io.perfmark"
],
"io.sentry:sentry": [
"io.sentry",
"io.sentry.backpressure",
"io.sentry.cache",
"io.sentry.clientreport",
"io.sentry.config",
"io.sentry.exception",
"io.sentry.hints",
"io.sentry.instrumentation.file",
"io.sentry.internal.debugmeta",
"io.sentry.internal.gestures",
"io.sentry.internal.modules",
"io.sentry.internal.viewhierarchy",
"io.sentry.metrics",
"io.sentry.profilemeasurements",
"io.sentry.protocol",
"io.sentry.rrweb",
"io.sentry.transport",
"io.sentry.util",
"io.sentry.util.thread",
"io.sentry.vendor",
"io.sentry.vendor.gson.internal.bind.util",
"io.sentry.vendor.gson.stream"
],
"javax.activation:javax.activation-api": [
"javax.activation"
],
@@ -1966,7 +1814,6 @@
"kotlin.annotation",
"kotlin.collections",
"kotlin.collections.builders",
"kotlin.collections.jdk8",
"kotlin.collections.unsigned",
"kotlin.comparisons",
"kotlin.concurrent",
@@ -1975,36 +1822,24 @@
"kotlin.coroutines.cancellation",
"kotlin.coroutines.intrinsics",
"kotlin.coroutines.jvm.internal",
"kotlin.enums",
"kotlin.experimental",
"kotlin.internal",
"kotlin.internal.jdk7",
"kotlin.internal.jdk8",
"kotlin.io",
"kotlin.io.encoding",
"kotlin.io.path",
"kotlin.jdk7",
"kotlin.js",
"kotlin.jvm",
"kotlin.jvm.functions",
"kotlin.jvm.internal",
"kotlin.jvm.internal.markers",
"kotlin.jvm.internal.unsafe",
"kotlin.jvm.jdk8",
"kotlin.jvm.optionals",
"kotlin.math",
"kotlin.properties",
"kotlin.random",
"kotlin.random.jdk8",
"kotlin.ranges",
"kotlin.reflect",
"kotlin.sequences",
"kotlin.streams.jdk8",
"kotlin.system",
"kotlin.text",
"kotlin.text.jdk8",
"kotlin.time",
"kotlin.time.jdk8"
"kotlin.time"
],
"org.jetbrains:annotations": [
"org.intellij.lang.annotations",
@@ -2417,7 +2252,6 @@
"com.fasterxml.jackson.core:jackson-annotations",
"com.fasterxml.jackson.core:jackson-core",
"com.fasterxml.jackson.core:jackson-databind",
"com.github.stephenc.jcip:jcip-annotations",
"com.google.android:annotations",
"com.google.api.grpc:proto-google-common-protos",
"com.google.auth:google-auth-library-credentials",
@@ -2436,12 +2270,8 @@
"com.google.protobuf:protobuf-java",
"com.google.re2j:re2j",
"com.google.truth:truth",
"com.nimbusds:nimbus-jose-jwt",
"com.squareup.okhttp3:okhttp",
"com.squareup.okhttp3:okhttp-sse",
"com.squareup.okhttp:okhttp",
"com.squareup.okio:okio",
"com.squareup.okio:okio-jvm",
"com.thesamet.scalapb:compilerplugin_3",
"com.thesamet.scalapb:lenses_3",
"com.thesamet.scalapb:protoc-bridge_2.13",
@@ -2482,7 +2312,6 @@
"io.opencensus:opencensus-contrib-grpc-metrics",
"io.opencensus:opencensus-contrib-http-util",
"io.perfmark:perfmark-api",
"io.sentry:sentry",
"javax.activation:javax.activation-api",
"javax.xml.bind:jaxb-api",
"joda-time:joda-time",
@@ -2495,8 +2324,6 @@
"org.hamcrest:hamcrest-core",
"org.jetbrains.kotlin:kotlin-stdlib",
"org.jetbrains.kotlin:kotlin-stdlib-common",
"org.jetbrains.kotlin:kotlin-stdlib-jdk7",
"org.jetbrains.kotlin:kotlin-stdlib-jdk8",
"org.jetbrains:annotations",
"org.json4s:json4s-ast_3",
"org.json4s:json4s-core_3",
-252
View File
@@ -1,252 +0,0 @@
events {
worker_connections 1024;
}
http {
# Allow large request bodies for game uploads (default is 1MB)
client_max_body_size 50M;
# Logging
log_format grpc_json escape=json '{'
'"time":"$time_iso8601",'
'"client":"$remote_addr",'
'"uri":"$uri",'
'"status":$status,'
'"grpc_status":"$sent_http_grpc_status",'
'"request_time":$request_time,'
'"upstream_time":"$upstream_response_time"'
'}';
access_log /var/log/nginx/access.log grpc_json;
error_log /var/log/nginx/error.log warn;
# Rate limiting zone
limit_req_zone $binary_remote_addr zone=grpc_limit:10m rate=100r/s;
# Docker DNS resolver - re-resolve hostnames every 10s
# This prevents stale IP caching when containers restart
resolver 127.0.0.11 valid=10s ipv6=off;
# Eagle backend address - configured via variable for blue-green deployments
# Using a variable makes nginx resolve the hostname at request time, not config load time
# This allows nginx to reload even when the backend is temporarily down
# For blue-green deployments, this is switched between eagle-blue:40032 and eagle-green:40032
# by scripts/deploy-blue-green.sh, then nginx is reloaded with 'nginx -s reload'
map $host $eagle_backend {
default "eagle-blue:40032";
}
# HTTP server for Let's Encrypt challenge and redirect
server {
listen 80;
listen [::]:80;
server_name prod.eagle0.net;
# Let's Encrypt challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect all other HTTP to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS server for gRPC
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name prod.eagle0.net;
# SSL certificates (managed by certbot)
ssl_certificate /etc/letsencrypt/live/prod.eagle0.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/prod.eagle0.net/privkey.pem;
# SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# gRPC proxy for Eagle service
location /net.eagle0.eagle.api.Eagle {
# Rate limiting
limit_req zone=grpc_limit burst=50 nodelay;
# gRPC proxy - uses variable for blue-green deployment support
grpc_pass grpc://$eagle_backend;
# Timeouts for long-running streams
grpc_read_timeout 1200s;
grpc_send_timeout 1200s;
grpc_socket_keepalive on;
# Error handling
error_page 502 = /error502grpc;
}
# gRPC proxy for Auth service (routes to Go auth service, not Eagle)
location /net.eagle0.eagle.api.auth.Auth {
# Rate limiting
limit_req zone=grpc_limit burst=50 nodelay;
# Route to auth service directly (not through Eagle)
set $auth_backend "auth:40033";
grpc_pass grpc://$auth_backend;
# Timeouts
grpc_read_timeout 30s;
grpc_send_timeout 30s;
# Error handling
error_page 502 = /error502grpc;
}
# OAuth callback endpoint (proxied to Go auth service)
location /oauth/callback {
proxy_pass http://auth:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Invitation landing page (proxied to Go auth service)
location /invite/ {
proxy_pass http://auth:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Health check endpoint
location /health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
# gRPC error handling
location = /error502grpc {
internal;
default_type application/grpc;
add_header grpc-status 14;
add_header grpc-message "unavailable";
return 204;
}
}
# HTTPS server for Go Auth service (port 40033)
# Clients connect here directly for OAuth RPCs in Phase 2
server {
listen 40033 ssl;
listen [::]:40033 ssl;
http2 on;
server_name prod.eagle0.net;
# SSL certificates (same as main server)
ssl_certificate /etc/letsencrypt/live/prod.eagle0.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/prod.eagle0.net/privkey.pem;
# SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# gRPC proxy for Auth service
# Uses variable-based resolution so nginx can start even if auth isn't ready yet
# DNS is cached by the resolver directive (valid=10s)
location /net.eagle0.eagle.api.auth.Auth {
# Rate limiting
limit_req zone=grpc_limit burst=50 nodelay;
# Dynamic upstream resolution (doesn't block nginx startup)
set $auth_backend "auth:40033";
grpc_pass grpc://$auth_backend;
# Timeouts
grpc_read_timeout 30s;
grpc_send_timeout 30s;
# Error handling
error_page 502 = /error502grpc;
}
# gRPC proxy for Admin service
location /net.eagle0.eagle.api.admin.Admin {
# Rate limiting
limit_req zone=grpc_limit burst=50 nodelay;
# Dynamic upstream resolution (doesn't block nginx startup)
set $auth_backend "auth:40033";
grpc_pass grpc://$auth_backend;
# Timeouts
grpc_read_timeout 30s;
grpc_send_timeout 30s;
# Error handling
error_page 502 = /error502grpc;
}
# Health check endpoint
location /health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
# gRPC error handling
location = /error502grpc {
internal;
default_type application/grpc;
add_header grpc-status 14;
add_header grpc-message "unavailable";
return 204;
}
}
# HTTP server for Admin Console (Let's Encrypt + redirect)
server {
listen 80;
listen [::]:80;
server_name admin.prod.eagle0.net admin.eagle0.net;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS server for Admin Console
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name admin.prod.eagle0.net admin.eagle0.net;
ssl_certificate /etc/letsencrypt/live/admin.eagle0.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/admin.eagle0.net/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
location / {
proxy_pass http://admin:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
+2 -3
View File
@@ -3,8 +3,7 @@
set -euxo pipefail
/bin/echo "building darwin bundle"
bazel build --config=mactools @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle
ZIP_LOCATION=$(bazel cquery --config=mactools --output=files @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle 2>/dev/null)
/usr/bin/unzip -o $ZIP_LOCATION -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
bazel build --noincompatible_enable_cc_toolchain_resolution @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle
/usr/bin/unzip -o bazel-bin/external/net_eagle0_unity_godice/darwin/framework/DarwinGodiceBundle.zip -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
/usr/bin/plutil -convert xml1 src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/DarwinGodiceBundle.bundle/Contents/Info.plist
+2 -3
View File
@@ -5,9 +5,8 @@ set -euxo pipefail
/bin/echo "build plugins"
/bin/echo "building darwin bundle"
bazel build --config=mactools @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle
ZIP_LOCATION=$(bazel cquery --config=mactools --output=files @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle 2>/dev/null)
/usr/bin/unzip -o $ZIP_LOCATION -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
bazel build --noincompatible_enable_cc_toolchain_resolution @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle
/usr/bin/unzip -o bazel-bin/external/net_eagle0_unity_godice/darwin/framework/DarwinGodiceBundle.zip -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
/usr/bin/plutil -convert xml1 src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/DarwinGodiceBundle.bundle/Contents/Info.plist
-88
View File
@@ -1,88 +0,0 @@
#!/usr/bin/env bash
#
# Code sign a macOS .app bundle for distribution
# Usage: codesign_mac_app.sh <app_path> [entitlements_path]
#
# Environment variables:
# SIGNING_IDENTITY - The signing identity (default: "Developer ID Application")
# KEYCHAIN_PASSWORD - Password to unlock the build keychain (optional)
set -euxo pipefail
APP_PATH="$1"
ENTITLEMENTS_PATH="${2:-}"
SIGNING_IDENTITY="${SIGNING_IDENTITY:-Developer ID Application}"
if [ ! -d "$APP_PATH" ]; then
echo "ERROR: App not found at $APP_PATH"
exit 1
fi
# Unlock keychain if password provided
if [ -n "${KEYCHAIN_PASSWORD:-}" ]; then
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain || true
fi
echo "=== Signing nested components first ==="
# Sign all dylibs
find "$APP_PATH" -name "*.dylib" -print0 | while IFS= read -r -d '' item; do
echo "Signing dylib: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign all bundles (plugins)
find "$APP_PATH" -name "*.bundle" -print0 | while IFS= read -r -d '' item; do
echo "Signing bundle: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign XPC services (inside Sparkle framework)
find "$APP_PATH" -name "*.xpc" -print0 | while IFS= read -r -d '' item; do
echo "Signing XPC service: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign nested apps (like Sparkle's Updater.app)
find "$APP_PATH" -path "*/Frameworks/*.app" -print0 | while IFS= read -r -d '' item; do
echo "Signing nested app: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign standalone executables inside frameworks (like Autoupdate)
find "$APP_PATH" -path "*/Frameworks/*/Versions/*/Autoupdate" -type f -print0 | while IFS= read -r -d '' item; do
echo "Signing executable: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign all frameworks (after their contents are signed)
find "$APP_PATH" -name "*.framework" -print0 | while IFS= read -r -d '' item; do
echo "Signing framework: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
echo "=== Signing main app bundle ==="
if [ -n "$ENTITLEMENTS_PATH" ] && [ -f "$ENTITLEMENTS_PATH" ]; then
echo "Using entitlements: $ENTITLEMENTS_PATH"
codesign --force --verify --verbose --timestamp --options runtime \
--entitlements "$ENTITLEMENTS_PATH" \
--sign "$SIGNING_IDENTITY" "$APP_PATH"
else
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$APP_PATH"
fi
echo "=== Verifying signature ==="
codesign --verify --verbose=4 "$APP_PATH"
echo "=== Checking Gatekeeper assessment ==="
spctl --assess --type exec -v "$APP_PATH" || echo "Note: Gatekeeper may reject until notarized"
echo "Code signing complete: $APP_PATH"
-353
View File
@@ -1,353 +0,0 @@
#!/bin/bash
#
# Blue-Green Deployment Script for Eagle Server
#
# This script performs a zero-downtime deployment by:
# 1. Starting the new version on a staging port (green)
# 2. Waiting for it to become healthy
# 3. Running warmup traffic to pre-heat the JIT
# 4. Stopping the old version (blue) - which flushes state to storage
# 5. Switching nginx to route traffic to green
# 6. Users reconnect, triggering lazy game loading from fresh storage
# 7. Cleaning up
#
# Usage: ./deploy-blue-green.sh [NEW_IMAGE_TAG]
#
# Example:
# ./deploy-blue-green.sh latest
# ./deploy-blue-green.sh sha-abc123
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_DIR="${APP_DIR:-/opt/eagle0}"
NGINX_CONF="${APP_DIR}/nginx/nginx.conf"
COMPOSE_FILE="${APP_DIR}/docker-compose.prod.yml"
WARMUP_SCRIPT="${SCRIPT_DIR}/warmup-eagle.sh"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# Determine which instance is currently active
get_active_instance() {
if grep -q "eagle-blue:40032" "${NGINX_CONF}"; then
echo "blue"
elif grep -q "eagle-green:40032" "${NGINX_CONF}"; then
echo "green"
else
log_error "Cannot determine active instance from nginx config"
exit 1
fi
}
# Pull image with retry using crane (handles OCI/Docker digest mismatch)
pull_with_retry() {
local image=$1
local max_attempts=${2:-3}
local attempt=1
# Skip pull if image already exists locally (e.g., CI already pulled it)
if docker image inspect "${image}" &>/dev/null; then
log_info "Image ${image} already exists locally, skipping pull"
return 0
fi
# Use crane if available (handles OCI format correctly)
if [ -x "${APP_DIR}/crane" ]; then
while [ $attempt -le $max_attempts ]; do
log_info "Pulling image with crane (attempt ${attempt}/${max_attempts})..."
if "${APP_DIR}/crane" pull "${image}" /tmp/image.tar && docker load -i /tmp/image.tar; then
rm -f /tmp/image.tar
log_info "Image pulled and loaded successfully"
return 0
fi
rm -f /tmp/image.tar
log_warn "Pull failed, retrying in 5 seconds..."
sleep 5
attempt=$((attempt + 1))
done
else
# Fallback to docker pull if crane not available
log_warn "crane not found at ${APP_DIR}/crane, falling back to docker pull"
while [ $attempt -le $max_attempts ]; do
log_info "Pulling image (attempt ${attempt}/${max_attempts})..."
if docker pull "${image}"; then
log_info "Image pulled successfully"
return 0
fi
log_warn "Pull failed, retrying in 5 seconds..."
sleep 5
attempt=$((attempt + 1))
done
fi
log_error "Failed to pull image after ${max_attempts} attempts"
return 1
}
# Wait for a container to be healthy
wait_for_healthy() {
local container=$1
local max_attempts=${2:-60}
local attempt=1
log_info "Waiting for ${container} to become healthy..."
while [ $attempt -le $max_attempts ]; do
health=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "unknown")
if [ "$health" = "healthy" ]; then
log_info "${container} is healthy"
return 0
fi
echo -n "."
sleep 2
attempt=$((attempt + 1))
done
echo ""
log_error "${container} did not become healthy after $((max_attempts * 2)) seconds"
return 1
}
# Main deployment logic
main() {
local new_tag="${1:-latest}"
local registry="registry.digitalocean.com/eagle0/eagle-server"
local new_image="${registry}:${new_tag}"
log_info "Starting blue-green deployment"
log_info "New image: ${new_image}"
cd "${APP_DIR}"
# Determine current active instance
local active=$(get_active_instance)
local staging
if [ "$active" = "blue" ]; then
staging="green"
else
staging="blue"
fi
log_info "Active instance: eagle-${active}"
log_info "Staging instance: eagle-${staging}"
# Pull the new image (with retry for intermittent registry issues)
if ! pull_with_retry "${new_image}" 3; then
log_error "Failed to pull new image, aborting deployment"
exit 1
fi
# Start staging instance with new image (and its jfr-sidecar)
log_info "Starting eagle-${staging} with new image..."
if [ "$staging" = "green" ]; then
EAGLE_IMAGE_NEW="${new_image}" docker compose -f "${COMPOSE_FILE}" --profile blue-green up -d eagle-green jfr-sidecar-green
else
EAGLE_IMAGE="${new_image}" docker compose -f "${COMPOSE_FILE}" up -d eagle-blue jfr-sidecar
fi
# Wait for staging to be healthy
if ! wait_for_healthy "eagle-${staging}" 90; then
log_error "Staging instance failed health check, aborting deployment"
docker compose -f "${COMPOSE_FILE}" stop "eagle-${staging}"
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${staging}"
exit 1
fi
# Run warmup/smoke test
local staging_port
if [ "$staging" = "green" ]; then
staging_port=40034
else
staging_port=40032
fi
log_info "Running warmup against eagle-${staging}..."
if [ -x "${WARMUP_SCRIPT}" ]; then
if ! "${WARMUP_SCRIPT}" "localhost:${staging_port}"; then
log_error "Warmup/smoke test failed, aborting deployment"
docker compose -f "${COMPOSE_FILE}" stop "eagle-${staging}"
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${staging}"
exit 1
fi
else
log_warn "Warmup script not found at ${WARMUP_SCRIPT}, skipping warmup"
log_warn "JIT will be cold on first requests"
fi
# Backup games.e0es BEFORE stopping the active instance
# Critical: The old server may wipe games.e0es on shutdown if it doesn't have the merge fix
local backup_timestamp
backup_timestamp=$(date +%Y%m%d_%H%M%S)
log_info "Creating S3 backup of games.e0es (backup_${backup_timestamp})..."
if command -v s3cmd &> /dev/null; then
if s3cmd put "${APP_DIR}/saves/games.e0es" "s3://eagle0/eagle/save/backups/games.e0es.${backup_timestamp}" 2>/dev/null; then
log_info "Backup created: s3://eagle0/eagle/save/backups/games.e0es.${backup_timestamp}"
else
log_warn "S3 backup failed (s3cmd put), continuing deployment"
fi
else
log_warn "s3cmd not found, skipping S3 backup"
fi
# Prepare nginx config and .env files
log_info "Updating nginx config to point to eagle-${staging}..."
if [ "$staging" = "green" ]; then
sed -i.bak 's/eagle-blue:40032/eagle-green:40032/g' "${NGINX_CONF}"
else
sed -i.bak 's/eagle-green:40032/eagle-blue:40032/g' "${NGINX_CONF}"
fi
local env_file="${APP_DIR}/.env"
if [ "$staging" = "green" ]; then
log_info "Updating .env for green instance..."
sed -i.bak '/^EAGLE_ADDR=/d; /^JFR_SIDECAR_ADDR=/d' "${env_file}" 2>/dev/null || true
echo "EAGLE_ADDR=eagle-green:40032" >> "${env_file}"
echo "JFR_SIDECAR_ADDR=jfr-sidecar-green:8081" >> "${env_file}"
else
log_info "Updating .env for blue instance..."
sed -i.bak '/^EAGLE_ADDR=/d; /^JFR_SIDECAR_ADDR=/d' "${env_file}" 2>/dev/null || true
echo "EAGLE_ADDR=eagle-blue:40032" >> "${env_file}"
echo "JFR_SIDECAR_ADDR=jfr-sidecar:8081" >> "${env_file}"
fi
# === CRITICAL SECTION: Minimize time between nginx switch and blue stop ===
# This reduces the window where users might get stale game data.
# Order matters:
# 1. Switch nginx to green (blue still running as fallback)
# 2. Immediately stop blue (flushes state to disk)
# 3. Users reconnecting to green will lazy-load fresh data from disk
# Recreate nginx to pick up new config from bind-mounted volume
# IMPORTANT: We must use --force-recreate, not restart, because:
# - 'restart' doesn't refresh bind-mounted files (keeps cached config)
# - 'reload' doesn't force DNS re-resolution in Docker
log_info "Switching traffic to eagle-${staging}..."
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate nginx
# Verify nginx picked up the correct config
local nginx_backend
nginx_backend=$(docker exec nginx grep -o 'eagle-[a-z]*:40032' /etc/nginx/nginx.conf | head -1)
if [ "$nginx_backend" != "eagle-${staging}:40032" ]; then
log_error "nginx config mismatch! Expected eagle-${staging}:40032, got ${nginx_backend}"
log_error "This should not happen - investigate bind mount issues"
exit 1
fi
log_info "Traffic now routing to eagle-${staging}"
# Immediately stop the old instance to flush state to disk
# This minimizes the window where green might lazy-load stale data
log_info "Stopping eagle-${active} (flushing state to storage)..."
docker compose -f "${COMPOSE_FILE}" stop "eagle-${active}"
# Brief pause to ensure disk flush completes before any lazy loads
log_info "Waiting for state flush to complete..."
sleep 3
# === END CRITICAL SECTION ===
# Restart admin to pick up new .env
log_info "Restarting admin service..."
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate admin
# Clean up old instance and its jfr-sidecar (in background, not critical path)
log_info "Cleaning up old eagle-${active} container..."
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${active}" &
if [ "$active" = "green" ]; then
docker compose -f "${COMPOSE_FILE}" rm -f "jfr-sidecar-green" 2>/dev/null &
else
docker compose -f "${COMPOSE_FILE}" rm -f "jfr-sidecar" 2>/dev/null &
fi
wait
log_info "Deployment complete!"
log_info "Active instance: eagle-${staging}"
}
# Ensure s3cmd is installed and configured for S3 backups
ensure_s3cmd() {
# Install s3cmd if not present
if ! command -v s3cmd &> /dev/null; then
log_info "Installing s3cmd for S3 backups..."
if command -v pip3 &> /dev/null; then
pip3 install --quiet s3cmd
elif command -v pip &> /dev/null; then
pip install --quiet s3cmd
elif command -v apt-get &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq s3cmd
else
log_warn "Cannot install s3cmd (no pip or apt-get), S3 backups will be skipped"
return 1
fi
if ! command -v s3cmd &> /dev/null; then
log_warn "s3cmd installation failed, S3 backups will be skipped"
return 1
fi
log_info "s3cmd installed successfully"
fi
# Configure s3cmd for DigitalOcean Spaces if not already configured
local s3cfg="${HOME}/.s3cfg"
if [ ! -f "${s3cfg}" ]; then
# Load credentials from .env
local env_file="${APP_DIR}/.env"
if [ -f "${env_file}" ]; then
# shellcheck source=/dev/null
source "${env_file}"
fi
if [ -z "${DO_SPACES_ACCESS_KEY:-}" ] || [ -z "${DO_SPACES_SECRET_KEY:-}" ]; then
log_warn "DO_SPACES_ACCESS_KEY or DO_SPACES_SECRET_KEY not set, S3 backups will be skipped"
return 1
fi
log_info "Configuring s3cmd for DigitalOcean Spaces..."
cat > "${s3cfg}" << EOF
[default]
access_key = ${DO_SPACES_ACCESS_KEY}
secret_key = ${DO_SPACES_SECRET_KEY}
host_base = sfo3.digitaloceanspaces.com
host_bucket = %(bucket)s.sfo3.digitaloceanspaces.com
use_https = True
EOF
log_info "s3cmd configured successfully"
fi
return 0
}
# Check for required tools
check_requirements() {
if ! command -v docker &> /dev/null; then
log_error "docker is required but not installed"
exit 1
fi
if ! command -v sed &> /dev/null; then
log_error "sed is required but not installed"
exit 1
fi
if [ ! -f "${NGINX_CONF}" ]; then
log_error "nginx config not found at ${NGINX_CONF}"
exit 1
fi
if [ ! -f "${COMPOSE_FILE}" ]; then
log_error "docker-compose file not found at ${COMPOSE_FILE}"
exit 1
fi
# Ensure s3cmd is available for backups (non-fatal if it fails)
ensure_s3cmd || true
}
# Run
check_requirements
main "$@"
+2 -4
View File
@@ -1,10 +1,8 @@
#!/usr/bin/env bash
curl -L "https://docs.google.com/spreadsheets/d/1pv-WMXReccddPwev_YG9IXEGznuGHrYjNNEZ0Rb-ZhM/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/shardok/settings.tsv
curl -L "https://docs.google.com/spreadsheets/d/1p6I5nUMcoAPHIcqikVgbBCFVnqN9dpOEVClbS_wOI7M/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/eagle/settings.tsv
curl -L "https://docs.google.com/spreadsheets/d/1pv-WMXReccddPwev_YG9IXEGznuGHrYjNNEZ0Rb-ZhM/export?gid=0&format=tsv" > src/main/resources/net/eagle0/shardok/settings.tsv
curl -L "https://docs.google.com/spreadsheets/d/1p6I5nUMcoAPHIcqikVgbBCFVnqN9dpOEVClbS_wOI7M/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/settings.tsv
bazel run //src/main/go/net/eagle0/build/settings_generator:settings_generator -- \
${PWD}/src/main/resources/net/eagle0/eagle/settings.tsv \
${PWD}/src/main/scala/net/eagle0/eagle/library/settings/
bazel run gazelle
+4 -4
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env bash
curl -L "https://docs.google.com/spreadsheets/d/1DHEsiv4cY4gE6AX3sVH82K__mpBD1aznIYCQwQxA_F0/export?gid=0&format=tsv" | tr -d '\r' > /tmp/names.tsv
curl -L "https://docs.google.com/spreadsheets/d/1DHEsiv4cY4gE6AX3sVH82K__mpBD1aznIYCQwQxA_F0/export?gid=0&format=tsv" > /tmp/names.tsv
bazel run //src/main/scala/net/eagle0/util:name_list_checker -- /tmp/names.tsv > src/main/resources/net/eagle0/names.tsv
bazel run //src/main/scala/net/eagle0/util:name_list_json_maker -- /tmp/names.tsv > src/main/resources/net/eagle0/names.json
curl -L "https://docs.google.com/spreadsheets/d/1NhvG73HKyVE36yGpkV2oJiSIXoNqQOYTr5ArLnucYL0/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/shardok/battalionTypes.tsv
curl -L "https://docs.google.com/spreadsheets/d/1pNWiyxIks2wJ1v7jRLFD24zrKHG2AfhC-nkWmQKQGN4/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/eagle/heroes.tsv
curl -L "https://docs.google.com/spreadsheets/d/1RUguq5eAQprsZwOOqiCc-1dg4Urc_6iJ6awZsFU4MeI/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/eagle/beasts.tsv
curl -L "https://docs.google.com/spreadsheets/d/1NhvG73HKyVE36yGpkV2oJiSIXoNqQOYTr5ArLnucYL0/export?gid=0&format=tsv" > src/main/resources/net/eagle0/shardok/battalionTypes.tsv
curl -L "https://docs.google.com/spreadsheets/d/1pNWiyxIks2wJ1v7jRLFD24zrKHG2AfhC-nkWmQKQGN4/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/heroes.tsv
curl -L "https://docs.google.com/spreadsheets/d/1RUguq5eAQprsZwOOqiCc-1dg4Urc_6iJ6awZsFU4MeI/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/beasts.tsv
#curl -L "https://docs.google.com/spreadsheets/d/1Z-60cJ_N1IasvqpVb5awKEkIYznEeR2IZSdli47oW88/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/province_map.tsv
${PWD}/scripts/dlSettings.sh
-473
View File
@@ -1,473 +0,0 @@
#!/bin/bash
#
# generate_changelog.sh
#
# Generates a weekly changelog from merged PRs, uses Claude to create a synopsis,
# and sends an HTML email via Fastmail JMAP API.
#
# Usage: ./scripts/generate_changelog.sh [--dry-run]
#
# Configuration files (in ~/.config/eagle0/):
# fastmail_token - API token (required)
# changelog_recipient - Email addresses, one per line (optional, defaults to sender)
#
# To set up:
# mkdir -p ~/.config/eagle0
# echo 'your-token' > ~/.config/eagle0/fastmail_token
# chmod 600 ~/.config/eagle0/fastmail_token
#
# # Optional: configure recipients (one per line, # for comments)
# cat > ~/.config/eagle0/changelog_recipient << EOF
# alice@example.com
# bob@example.com
# EOF
#
# The script tracks its last run using a git tag 'changelog-last-run'.
# On first run (no tag), it defaults to the previous Friday at 4pm.
set -euo pipefail
# Ensure homebrew binaries are in PATH
export PATH="/opt/homebrew/bin:$PATH"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TAG_NAME="changelog-last-run"
DRY_RUN=false
FASTMAIL_API="https://api.fastmail.com/jmap/api/"
CONFIG_DIR="$HOME/.config/eagle0"
TOKEN_FILE="$CONFIG_DIR/fastmail_token"
RECIPIENT_FILE="$CONFIG_DIR/changelog_recipient"
# Load API token from file or environment
load_api_token() {
# Environment variable takes precedence
if [[ -n "${FASTMAIL_API_TOKEN:-}" ]]; then
return 0
fi
# Try loading from config file
if [[ -f "$TOKEN_FILE" ]]; then
FASTMAIL_API_TOKEN=$(cat "$TOKEN_FILE" | tr -d '[:space:]')
if [[ -n "$FASTMAIL_API_TOKEN" ]]; then
echo "Loaded API token from $TOKEN_FILE"
export FASTMAIL_API_TOKEN
return 0
fi
fi
return 1
}
# Load recipient emails from config file (one per line)
# Returns JSON array fragment like: {"email": "a@b.com"}, {"email": "c@d.com"}
load_recipients_json() {
local recipients=""
if [[ -f "$RECIPIENT_FILE" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
# Skip empty lines and comments
line=$(echo "$line" | tr -d '[:space:]')
[[ -z "$line" || "$line" == \#* ]] && continue
if [[ -n "$recipients" ]]; then
recipients="$recipients, "
fi
recipients="$recipients{\"email\": \"$line\"}"
done < "$RECIPIENT_FILE"
fi
echo "$recipients"
}
# Get human-readable list of recipients
load_recipients_display() {
if [[ -f "$RECIPIENT_FILE" ]]; then
grep -v '^#' "$RECIPIENT_FILE" | grep -v '^[[:space:]]*$' | tr '\n' ', ' | sed 's/, $//'
fi
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--dry-run)
DRY_RUN=true
shift
;;
*)
echo "Unknown option: $1"
echo "Usage: $0 [--dry-run]"
exit 1
;;
esac
done
cd "$REPO_ROOT"
# Get the cutoff date - either from tag or previous Friday 4pm
get_cutoff_date() {
# Try to get the date from the tag
if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then
# Get the commit date of the tagged commit
git log -1 --format="%aI" "$TAG_NAME"
else
# Calculate previous Friday at 4pm
# Get current day of week (1=Monday, 7=Sunday)
local dow=$(date +%u)
local days_since_friday
if [[ $dow -ge 5 ]]; then
# Friday (5), Saturday (6), or Sunday (7)
days_since_friday=$((dow - 5))
else
# Monday (1) through Thursday (4)
days_since_friday=$((dow + 2))
fi
# Get previous Friday at 4pm in ISO format
if [[ "$(uname)" == "Darwin" ]]; then
date -v-"${days_since_friday}d" -v16H -v0M -v0S +"%Y-%m-%dT%H:%M:%S%z"
else
date -d "$days_since_friday days ago 16:00:00" --iso-8601=seconds
fi
fi
}
# Fetch merged PRs since the cutoff date
fetch_merged_prs() {
local since_date="$1"
local output_file="$2"
echo "Fetching PRs merged since: $since_date"
# Use gh to search for merged PRs
gh pr list \
--state merged \
--base main \
--json number,title,body,mergedAt,author \
--jq ".[] | select(.mergedAt >= \"$since_date\")" \
> "$output_file.json"
# Format the output nicely
echo "# Merged PRs since $since_date" > "$output_file"
echo "" >> "$output_file"
# Process each PR
jq -r '
"## PR #\(.number): \(.title)\n" +
"Author: \(.author.login)\n" +
"Merged: \(.mergedAt)\n\n" +
"### Description\n" +
(.body // "(No description)") +
"\n\n---\n"
' "$output_file.json" >> "$output_file"
# Count PRs
local pr_count=$(jq -s 'length' "$output_file.json")
echo "Found $pr_count merged PRs"
rm -f "$output_file.json"
if [[ $pr_count -eq 0 ]]; then
echo "No PRs found since $since_date"
return 1
fi
return 0
}
# Generate synopsis using Claude
generate_synopsis() {
local input_file="$1"
local output_file="$2"
echo "Generating synopsis with Claude..."
# Create a prompt file to avoid shell escaping issues
local prompt_file="/tmp/eagle0_prompt_$$.txt"
# Get repo URL for PR links
local repo_url=$(gh repo view --json url -q '.url')
cat > "$prompt_file" <<PROMPT_HEADER
You are summarizing changes for a weekly engineering update email.
Read the following list of merged PRs and create a concise synopsis grouped by theme/feature/area of the codebase.
Structure:
1. <h1> title (e.g., "Eagle0 Weekly Update")
2. <h2>BLUF</h2> (Bottom Line Up Front) - A short prose paragraph (2-4 sentences) highlighting the 1-3 most important changes this week and what to look for when testing. This should be conversational and help readers quickly understand what matters most.
3. Synopsis sections (<h2> headings with bullet point summaries)
4. <hr> divider
5. <h2>PR Details</h2> with the same groupings, but smaller (<h3> headings) and listing PR links
- Format each PR as: <a href="${repo_url}/pull/NUMBER">#NUMBER</a>: Title
Guidelines for the SYNOPSIS sections:
- Group related changes together under clear headings (use <h2> tags)
- Use bullet points (<ul><li>) for individual changes
- Highlight any significant new features, breaking changes, or important fixes
- Keep the tone professional but accessible
- Don't include PR numbers in the synopsis - focus on what changed and why it matters
IMPORTANT: Output valid HTML that can be used directly in an email body. Do NOT wrap in \`\`\`html code blocks - just output the raw HTML.
Here are the merged PRs:
PROMPT_HEADER
cat "$input_file" >> "$prompt_file"
echo "" >> "$prompt_file"
echo "Generate the synopsis now:" >> "$prompt_file"
# Use Claude CLI to generate the synopsis, wrapped in proper HTML with charset
local raw_output="/tmp/eagle0_raw_$$.html"
cat "$prompt_file" | claude --print > "$raw_output"
# Wrap in HTML document with UTF-8 charset
cat > "$output_file" <<'HTML_HEAD'
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
HTML_HEAD
cat "$raw_output" >> "$output_file"
echo "</body></html>" >> "$output_file"
rm -f "$prompt_file" "$raw_output"
echo "Synopsis generated at: $output_file"
}
# Get Fastmail session info (account ID, identity ID, drafts mailbox ID)
get_fastmail_session() {
echo "Fetching Fastmail session info..." >&2
# Get session
local session=$(curl -s \
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
"https://api.fastmail.com/jmap/session")
# Extract account ID (first account)
FASTMAIL_ACCOUNT_ID=$(echo "$session" | jq -r '.primaryAccounts["urn:ietf:params:jmap:mail"]')
if [[ -z "$FASTMAIL_ACCOUNT_ID" || "$FASTMAIL_ACCOUNT_ID" == "null" ]]; then
echo "Error: Could not get Fastmail account ID. Check your API token." >&2
return 1
fi
echo "Account ID: $FASTMAIL_ACCOUNT_ID" >&2
# Get identity ID
local identity_response=$(curl -s \
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST \
-d "{
\"using\": [\"urn:ietf:params:jmap:core\", \"urn:ietf:params:jmap:mail\", \"urn:ietf:params:jmap:submission\"],
\"methodCalls\": [
[\"Identity/get\", {\"accountId\": \"$FASTMAIL_ACCOUNT_ID\"}, \"0\"]
]
}" \
"$FASTMAIL_API")
FASTMAIL_IDENTITY_ID=$(echo "$identity_response" | jq -r '.methodResponses[0][1].list[0].id')
FASTMAIL_FROM_EMAIL=$(echo "$identity_response" | jq -r '.methodResponses[0][1].list[0].email')
if [[ -z "$FASTMAIL_IDENTITY_ID" || "$FASTMAIL_IDENTITY_ID" == "null" ]]; then
echo "Error: Could not get Fastmail identity ID." >&2
return 1
fi
echo "Identity ID: $FASTMAIL_IDENTITY_ID (${FASTMAIL_FROM_EMAIL})" >&2
# Get drafts mailbox ID
local mailbox_response=$(curl -s \
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST \
-d "{
\"using\": [\"urn:ietf:params:jmap:core\", \"urn:ietf:params:jmap:mail\"],
\"methodCalls\": [
[\"Mailbox/query\", {\"accountId\": \"$FASTMAIL_ACCOUNT_ID\", \"filter\": {\"role\": \"drafts\"}}, \"0\"]
]
}" \
"$FASTMAIL_API")
FASTMAIL_DRAFTS_ID=$(echo "$mailbox_response" | jq -r '.methodResponses[0][1].ids[0]')
if [[ -z "$FASTMAIL_DRAFTS_ID" || "$FASTMAIL_DRAFTS_ID" == "null" ]]; then
echo "Error: Could not get Fastmail drafts mailbox ID." >&2
return 1
fi
echo "Drafts mailbox ID: $FASTMAIL_DRAFTS_ID" >&2
return 0
}
# Send email via Fastmail JMAP API
send_email_fastmail() {
local synopsis_file="$1"
local recipients_json="$2" # JSON array fragment: {"email": "a@b.com"}, {"email": "c@d.com"}
local subject="Eagle0 Weekly Changelog - $(date +%Y-%m-%d)"
local html_body=$(cat "$synopsis_file" | jq -Rs .)
echo "Sending email via Fastmail JMAP API..."
# Create the email and send it in one request
local response=$(curl -s \
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST \
-d "{
\"using\": [
\"urn:ietf:params:jmap:core\",
\"urn:ietf:params:jmap:mail\",
\"urn:ietf:params:jmap:submission\"
],
\"methodCalls\": [
[\"Email/set\", {
\"accountId\": \"$FASTMAIL_ACCOUNT_ID\",
\"create\": {
\"draft\": {
\"from\": [{\"email\": \"$FASTMAIL_FROM_EMAIL\"}],
\"to\": [$recipients_json],
\"subject\": \"$subject\",
\"mailboxIds\": {\"$FASTMAIL_DRAFTS_ID\": true},
\"keywords\": {\"\$draft\": true},
\"htmlBody\": [{\"partId\": \"body\", \"type\": \"text/html\"}],
\"bodyValues\": {
\"body\": {
\"charset\": \"utf-8\",
\"value\": $html_body
}
}
}
}
}, \"0\"],
[\"EmailSubmission/set\", {
\"accountId\": \"$FASTMAIL_ACCOUNT_ID\",
\"onSuccessDestroyEmail\": [\"#sendIt\"],
\"create\": {
\"sendIt\": {
\"emailId\": \"#draft\",
\"identityId\": \"$FASTMAIL_IDENTITY_ID\"
}
}
}, \"1\"]
]
}" \
"$FASTMAIL_API")
# Check for errors
local error=$(echo "$response" | jq -r '.methodResponses[0][1].notCreated.draft.description // empty')
if [[ -n "$error" ]]; then
echo "Error creating email: $error" >&2
echo "Full response: $response" >&2
return 1
fi
local send_error=$(echo "$response" | jq -r '.methodResponses[1][1].notCreated.sendIt.description // empty')
if [[ -n "$send_error" ]]; then
echo "Error sending email: $send_error" >&2
echo "Full response: $response" >&2
return 1
fi
echo "Email sent successfully"
}
# Update the tag to mark this run
update_tag() {
echo "Updating $TAG_NAME tag..."
# Delete existing tag if present
git tag -d "$TAG_NAME" 2>/dev/null || true
git push origin --delete "$TAG_NAME" 2>/dev/null || true
# Create new tag at HEAD
git tag "$TAG_NAME"
git push origin "$TAG_NAME"
echo "Tag updated to current HEAD"
}
# Main
main() {
echo "=== Eagle0 Weekly Changelog Generator ==="
echo ""
# Load API token (only required for actual send)
if [[ "$DRY_RUN" != "true" ]]; then
if ! load_api_token; then
echo "Error: No Fastmail API token found."
echo ""
echo "To create a token:"
echo "1. Go to Fastmail Settings -> Password & Security -> API tokens"
echo "2. Create a new token with 'Email submission' scope"
echo "3. Save it using one of these methods:"
echo ""
echo " Option A (recommended): Store in config file"
echo " mkdir -p ~/.config/eagle0"
echo " echo 'your-token' > ~/.config/eagle0/fastmail_token"
echo " chmod 600 ~/.config/eagle0/fastmail_token"
echo ""
echo " Option B: Set environment variable"
echo " export FASTMAIL_API_TOKEN='your-token'"
exit 1
fi
fi
# Get cutoff date
local cutoff_date=$(get_cutoff_date)
echo "Cutoff date: $cutoff_date"
# Create temp files
local pr_file="/tmp/eagle0_prs_$(date +%s).md"
local synopsis_file="/tmp/eagle0_synopsis_$(date +%s).html"
# Fetch PRs
if ! fetch_merged_prs "$cutoff_date" "$pr_file"; then
echo "No changes to report. Exiting."
exit 0
fi
echo ""
echo "PR details saved to: $pr_file"
# Generate synopsis
generate_synopsis "$pr_file" "$synopsis_file"
if [[ "$DRY_RUN" == "true" ]]; then
echo ""
echo "=== DRY RUN - Synopsis content ==="
cat "$synopsis_file"
echo ""
echo "=== DRY RUN - Skipping email send and tag update ==="
else
# Get Fastmail session info
if ! get_fastmail_session; then
echo "Failed to get Fastmail session info. Exiting."
exit 1
fi
# Determine recipients (from config file, or default to sender)
local recipients_json=$(load_recipients_json)
if [[ -z "$recipients_json" ]]; then
recipients_json="{\"email\": \"$FASTMAIL_FROM_EMAIL\"}"
echo "No recipients configured, sending to self ($FASTMAIL_FROM_EMAIL)"
else
local recipients_display=$(load_recipients_display)
echo "Sending to: $recipients_display"
fi
# Send email
send_email_fastmail "$synopsis_file" "$recipients_json"
# Update tag for next run
update_tag
fi
echo ""
echo "Done!"
echo "PR details: $pr_file"
echo "Synopsis: $synopsis_file"
}
main
-41
View File
@@ -1,41 +0,0 @@
// +build ignore
// Script to generate Ed25519 key pair for manifest signing.
// Run with: go run scripts/generate_manifest_keys.go
//
// This will output:
// - Private key (base64): Store as MANIFEST_SIGNING_KEY GitHub secret
// - Public key (base64): Embed in EagleInstaller for verification
package main
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"fmt"
"log"
)
func main() {
// Generate Ed25519 key pair
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
log.Fatalf("Failed to generate key pair: %v", err)
}
// Encode keys as base64
privateKeyB64 := base64.StdEncoding.EncodeToString(privateKey)
publicKeyB64 := base64.StdEncoding.EncodeToString(publicKey)
fmt.Println("=== Ed25519 Key Pair for Manifest Signing ===")
fmt.Println()
fmt.Println("PRIVATE KEY (store as GitHub secret MANIFEST_SIGNING_KEY):")
fmt.Println(privateKeyB64)
fmt.Println()
fmt.Println("PUBLIC KEY (embed in EagleInstaller.cs for verification):")
fmt.Println(publicKeyB64)
fmt.Println()
fmt.Printf("Private key size: %d bytes\n", len(privateKey))
fmt.Printf("Public key size: %d bytes\n", len(publicKey))
}
-93
View File
@@ -1,93 +0,0 @@
#!/usr/bin/env bash
#
# Inject Sparkle framework into a macOS .app bundle for auto-updates
# Usage: inject_sparkle.sh <app_path>
#
# Environment variables (required):
# SPARKLE_EDDSA_PUBLIC_KEY - EdDSA public key for verifying updates
#
# Optional environment variables:
# SPARKLE_FEED_URL - Appcast URL (default: https://assets.eagle0.net/mac/appcast.xml)
# SPARKLE_VERSION - Sparkle version to use (default: 2.6.4)
set -euxo pipefail
APP_PATH="$1"
SPARKLE_VERSION="${SPARKLE_VERSION:-2.6.4}"
SPARKLE_FEED_URL="${SPARKLE_FEED_URL:-https://assets.eagle0.net/mac/appcast.xml}"
SPARKLE_CACHE_DIR="/tmp/sparkle-cache"
if [ ! -d "$APP_PATH" ]; then
echo "ERROR: App not found at $APP_PATH"
exit 1
fi
if [ -z "${SPARKLE_EDDSA_PUBLIC_KEY:-}" ]; then
echo "ERROR: SPARKLE_EDDSA_PUBLIC_KEY environment variable not set"
exit 1
fi
# Download Sparkle if not cached
SPARKLE_DIR="$SPARKLE_CACHE_DIR/Sparkle-$SPARKLE_VERSION"
if [ ! -d "$SPARKLE_DIR/Sparkle.framework" ]; then
echo "=== Downloading Sparkle $SPARKLE_VERSION ==="
mkdir -p "$SPARKLE_CACHE_DIR"
SPARKLE_URL="https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz"
curl -L "$SPARKLE_URL" | tar -xJ -C "$SPARKLE_CACHE_DIR"
mv "$SPARKLE_CACHE_DIR/Sparkle-$SPARKLE_VERSION" "$SPARKLE_DIR" 2>/dev/null || true
# If the extracted directory doesn't match version pattern, it may just be "Sparkle"
if [ ! -d "$SPARKLE_DIR" ]; then
mkdir -p "$SPARKLE_DIR"
mv "$SPARKLE_CACHE_DIR/Sparkle.framework" "$SPARKLE_DIR/" 2>/dev/null || true
mv "$SPARKLE_CACHE_DIR/bin" "$SPARKLE_DIR/" 2>/dev/null || true
fi
fi
echo "=== Injecting Sparkle framework ==="
FRAMEWORKS_DIR="$APP_PATH/Contents/Frameworks"
mkdir -p "$FRAMEWORKS_DIR"
# Copy Sparkle framework
cp -R "$SPARKLE_DIR/Sparkle.framework" "$FRAMEWORKS_DIR/"
# Also copy the XPC services if present
if [ -d "$SPARKLE_DIR/Sparkle.framework/Versions/B/XPCServices" ]; then
echo "Sparkle XPC services present"
fi
echo "=== Updating Info.plist ==="
PLIST_PATH="$APP_PATH/Contents/Info.plist"
# Add Sparkle configuration to Info.plist
/usr/libexec/PlistBuddy -c "Delete :SUFeedURL" "$PLIST_PATH" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Add :SUFeedURL string '$SPARKLE_FEED_URL'" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$PLIST_PATH" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string '$SPARKLE_EDDSA_PUBLIC_KEY'" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Delete :SUEnableAutomaticChecks" "$PLIST_PATH" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Add :SUEnableAutomaticChecks bool true" "$PLIST_PATH"
# Set bundle version from git for Sparkle version comparison
VERSION=$(git describe --tags --always 2>/dev/null || echo "1.0.0")
BUILD_NUMBER=$(git rev-list --count HEAD 2>/dev/null || echo "1")
echo "Setting version: $VERSION (build $BUILD_NUMBER)"
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$PLIST_PATH" 2>/dev/null || \
/usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string '$VERSION'" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST_PATH" 2>/dev/null || \
/usr/libexec/PlistBuddy -c "Add :CFBundleVersion string '$BUILD_NUMBER'" "$PLIST_PATH"
# Add URL scheme for invitation codes (eagle0://invite?code=XXXX)
echo "=== Adding URL scheme for invitation codes ==="
/usr/libexec/PlistBuddy -c "Delete :CFBundleURLTypes" "$PLIST_PATH" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes array" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0 dict" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLName string 'com.Shardok-Games.eagle0'" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes array" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string 'eagle0'" "$PLIST_PATH"
echo "=== Sparkle injection complete ==="
echo "App: $APP_PATH"
echo "Feed URL: $SPARKLE_FEED_URL"
echo "Version: $VERSION (build $BUILD_NUMBER)"
-84
View File
@@ -1,84 +0,0 @@
#!/usr/bin/env bash
#
# Notarize a macOS .app bundle with Apple
# Usage: notarize_mac_app.sh <app_path>
#
# Environment variables (required):
# APPLE_ID - Apple Developer account email
# APP_SPECIFIC_PASSWORD - App-specific password for notarytool
# TEAM_ID - Apple Developer Team ID
set -euxo pipefail
APP_PATH="$1"
if [ ! -d "$APP_PATH" ]; then
echo "ERROR: App not found at $APP_PATH"
exit 1
fi
if [ -z "${APPLE_ID:-}" ] || [ -z "${APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${TEAM_ID:-}" ]; then
echo "ERROR: Required environment variables not set"
echo " APPLE_ID: ${APPLE_ID:-<not set>}"
echo " APP_SPECIFIC_PASSWORD: ${APP_SPECIFIC_PASSWORD:+<set>}"
echo " TEAM_ID: ${TEAM_ID:-<not set>}"
exit 1
fi
# Create ZIP for notarization submission
ZIP_PATH="${APP_PATH%.app}.zip"
echo "=== Creating ZIP for notarization: $ZIP_PATH ==="
ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH"
echo "=== Submitting to Apple for notarization ==="
SUBMIT_OUTPUT=$(xcrun notarytool submit "$ZIP_PATH" \
--apple-id "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" \
--team-id "$TEAM_ID" \
--wait 2>&1) || true
echo "$SUBMIT_OUTPUT"
# Extract submission ID and status (look for " status:" to avoid matching "Current status:")
SUBMISSION_ID=$(echo "$SUBMIT_OUTPUT" | grep "id:" | head -1 | awk '{print $2}')
STATUS=$(echo "$SUBMIT_OUTPUT" | grep "^ status:" | awk '{print $2}')
echo "Submission ID: $SUBMISSION_ID"
echo "Status: $STATUS"
# Clean up the zip
rm "$ZIP_PATH"
if [ "$STATUS" != "Accepted" ]; then
echo "=== Notarization failed! Fetching log for details ==="
xcrun notarytool log "$SUBMISSION_ID" \
--apple-id "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" \
--team-id "$TEAM_ID"
exit 1
fi
echo "=== Stapling notarization ticket to app ==="
# Retry stapling - Apple's CloudKit can have a brief delay after notarization completes
MAX_STAPLE_ATTEMPTS=5
STAPLE_ATTEMPT=1
while [ $STAPLE_ATTEMPT -le $MAX_STAPLE_ATTEMPTS ]; do
echo "Stapling attempt $STAPLE_ATTEMPT/$MAX_STAPLE_ATTEMPTS..."
if xcrun stapler staple "$APP_PATH"; then
echo "Stapling successful"
break
fi
if [ $STAPLE_ATTEMPT -eq $MAX_STAPLE_ATTEMPTS ]; then
echo "ERROR: Stapling failed after $MAX_STAPLE_ATTEMPTS attempts"
exit 1
fi
echo "Stapling failed, waiting 10 seconds before retry..."
sleep 10
STAPLE_ATTEMPT=$((STAPLE_ATTEMPT + 1))
done
echo "=== Verifying notarization ==="
xcrun stapler validate "$APP_PATH"
spctl --assess --type exec -v "$APP_PATH"
echo "Notarization complete: $APP_PATH"
-19
View File
@@ -1,19 +0,0 @@
#!/bin/bash
# Pre-commit hook wrapper for gazelle that fails if files are modified.
# This ensures BUILD files are in canonical format before committing.
set -e
# Run gazelle
bazel run //:gazelle 2>/dev/null
# Check if any BUILD files were modified
if ! git diff --quiet -- '*.bazel' '**/BUILD' 'WORKSPACE*'; then
echo ""
echo "ERROR: gazelle modified BUILD files. Please stage the changes and retry:"
echo ""
git diff --name-only -- '*.bazel' '**/BUILD' 'WORKSPACE*'
echo ""
echo "Run: git add -u && git commit"
exit 1
fi
-149
View File
@@ -1,149 +0,0 @@
#!/bin/bash
#
# Setup script for Eagle0 production droplet
# Run this on a fresh DigitalOcean droplet (Ubuntu 24.04)
#
# Usage: curl -sSL https://raw.githubusercontent.com/nolen777/eagle0/main/scripts/setup_droplet.sh | sudo bash
#
set -euo pipefail
DOMAIN="${DOMAIN:-eagle0.net}"
DEPLOY_USER="${DEPLOY_USER:-deploy}"
APP_DIR="/opt/eagle0"
echo "=== Eagle0 Production Server Setup ==="
echo "Domain: ${DOMAIN}"
echo "Deploy user: ${DEPLOY_USER}"
echo ""
# Check if running as root
if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root (use sudo)"
exit 1
fi
echo "=== Updating system ==="
apt-get update
apt-get upgrade -y
echo "=== Installing Docker ==="
if ! command -v docker &> /dev/null; then
curl -fsSL https://get.docker.com | sh
systemctl enable docker
systemctl start docker
else
echo "Docker already installed"
fi
echo "=== Installing Docker Compose plugin ==="
apt-get install -y docker-compose-plugin
echo "=== Installing additional utilities ==="
apt-get install -y \
curl \
wget \
git \
netcat-openbsd \
jq \
htop \
unattended-upgrades
echo "=== Configuring automatic security updates ==="
cat > /etc/apt/apt.conf.d/20auto-upgrades << 'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
EOF
echo "=== Creating deploy user ==="
if ! id "${DEPLOY_USER}" &>/dev/null; then
useradd -m -s /bin/bash -G docker "${DEPLOY_USER}"
mkdir -p "/home/${DEPLOY_USER}/.ssh"
chmod 700 "/home/${DEPLOY_USER}/.ssh"
chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "/home/${DEPLOY_USER}/.ssh"
echo ""
echo "*** IMPORTANT: Add your SSH public key to /home/${DEPLOY_USER}/.ssh/authorized_keys ***"
echo ""
else
echo "User ${DEPLOY_USER} already exists"
# Ensure user is in docker group
usermod -aG docker "${DEPLOY_USER}"
fi
echo "=== Creating application directory ==="
mkdir -p "${APP_DIR}"/{nginx,certbot/conf,certbot/www,saves}
chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "${APP_DIR}"
echo "=== Configuring Docker registry authentication ==="
echo ""
echo "*** IMPORTANT: Run the following command to authenticate with DigitalOcean Container Registry: ***"
echo " docker login registry.digitalocean.com"
echo ""
echo "=== Creating systemd service ==="
cat > /etc/systemd/system/eagle0.service << EOF
[Unit]
Description=Eagle0 Game Servers
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=${APP_DIR}
ExecStart=/usr/bin/docker compose -f docker-compose.prod.yml up -d
ExecStop=/usr/bin/docker compose -f docker-compose.prod.yml down
User=${DEPLOY_USER}
Group=${DEPLOY_USER}
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable eagle0
echo "=== Configuring firewall (UFW) ==="
if ! command -v ufw &> /dev/null; then
apt-get install -y ufw
fi
ufw default deny incoming
ufw default allow outgoing
ufw allow ssh
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
echo "=== Setting up log rotation ==="
cat > /etc/logrotate.d/eagle0 << EOF
/var/log/eagle0/*.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
create 0640 ${DEPLOY_USER} ${DEPLOY_USER}
sharedscripts
}
EOF
mkdir -p /var/log/eagle0
chown "${DEPLOY_USER}:${DEPLOY_USER}" /var/log/eagle0
echo ""
echo "=== Setup Complete ==="
echo ""
echo "Next steps:"
echo "1. Add SSH public key to /home/${DEPLOY_USER}/.ssh/authorized_keys"
echo "2. Copy docker-compose.prod.yml to ${APP_DIR}/"
echo "3. Copy nginx/nginx.conf to ${APP_DIR}/nginx/"
echo "4. Create .env file in ${APP_DIR}/ with OPENAI_API_KEY"
echo "5. Run: docker login registry.digitalocean.com"
echo "6. Get SSL certificate: (see init_ssl.sh)"
echo "7. Start services: systemctl start eagle0"
echo ""
echo "Server IP: $(curl -s ifconfig.me)"
echo ""
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
bazel run //src/main/go/net/eagle0/build/action_result_type_build_file_generator \
${PWD}/src/main/scala/net/eagle0/eagle/model/action_result/types/
-143
View File
@@ -1,143 +0,0 @@
#!/bin/bash
#
# Warmup Script for Eagle Server
#
# This script warms up the JIT compiler before switching traffic to a new instance.
# It uses the Go warmup tool which:
# 1. Creates a test game via bidirectional streaming
# 2. Posts an Improve command
# 3. Verifies action results and new commands
# 4. Cleans up the test game
#
# Usage: ./warmup-eagle.sh HOST:PORT
#
# Example:
# ./warmup-eagle.sh localhost:40032
# ./warmup-eagle.sh localhost:40034
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
HOST="${1:-localhost:40032}"
log_info "Warming up Eagle server at ${HOST}..."
# Try to find the Go warmup tool
WARMUP_TOOL=""
# Check if we're in the project directory with bazel
if [ -f "${PROJECT_ROOT}/WORKSPACE" ] || [ -f "${PROJECT_ROOT}/WORKSPACE.bazel" ]; then
# Try to find the pre-built binary
BAZEL_BIN="${PROJECT_ROOT}/bazel-bin/src/main/go/net/eagle0/warmup/warmup_/warmup"
if [ -x "${BAZEL_BIN}" ]; then
WARMUP_TOOL="${BAZEL_BIN}"
fi
fi
# Check for the warmup tool in common locations (for deployed environments)
if [ -z "${WARMUP_TOOL}" ]; then
for path in \
"${SCRIPT_DIR}/bin/warmup" \
"/opt/eagle0/scripts/bin/warmup" \
"/opt/eagle0/bin/warmup" \
"/usr/local/bin/eagle-warmup" \
"${SCRIPT_DIR}/warmup"; do
if [ -x "${path}" ]; then
WARMUP_TOOL="${path}"
break
fi
done
fi
# If we found the Go tool, use it
if [ -n "${WARMUP_TOOL}" ]; then
log_info "Using Go warmup tool: ${WARMUP_TOOL}"
# Use 5 minute timeout to allow for slow operations on cold JVM
if "${WARMUP_TOOL}" --address="${HOST}" --timeout=300s; then
log_info "Warmup complete!"
exit 0
else
log_error "Go warmup tool failed"
exit 1
fi
fi
# Fallback to grpcurl-based warmup
log_warn "Go warmup tool not found, falling back to grpcurl"
# Check for grpcurl
if ! command -v grpcurl &> /dev/null; then
log_error "Neither Go warmup tool nor grpcurl is available"
log_error "Build the warmup tool with: bazel build //src/main/go/net/eagle0/warmup"
log_error "Or install grpcurl: brew install grpcurl (macOS)"
exit 1
fi
# Warmup iterations
WARMUP_ITERATIONS=3
# 1. Call GetRunningGames multiple times - this exercises the gRPC layer and basic game access
log_info "Warming up GetRunningGames..."
for i in $(seq 1 ${WARMUP_ITERATIONS}); do
RESULT=$(grpcurl -plaintext -d '{}' "${HOST}" net.eagle0.eagle.api.Eagle/GetRunningGames 2>&1) || true
if echo "$RESULT" | grep -q "games\|{}"; then
echo -n "."
else
log_error "GetRunningGames failed on iteration $i"
exit 1
fi
done
echo " done"
# 2. Call GetSettings - exercises settings loading
log_info "Warming up GetSettings..."
for i in $(seq 1 ${WARMUP_ITERATIONS}); do
if grpcurl -plaintext -d '{}' "${HOST}" net.eagle0.eagle.api.Eagle/GetSettings > /dev/null 2>&1; then
echo -n "."
else
log_warn "GetSettings failed on iteration $i (non-fatal)"
fi
done
echo " done"
# 3. Call AddSettings with empty list - exercises settings path
log_info "Warming up AddSettings..."
for i in $(seq 1 ${WARMUP_ITERATIONS}); do
if grpcurl -plaintext -d '{"settings": []}' "${HOST}" net.eagle0.eagle.api.Eagle/AddSettings > /dev/null 2>&1; then
echo -n "."
else
log_warn "AddSettings failed on iteration $i (non-fatal)"
fi
done
echo " done"
# Final health check
log_info "Verifying server health..."
if grpcurl -plaintext -d '{}' "${HOST}" net.eagle0.eagle.api.Eagle/GetRunningGames > /dev/null 2>&1; then
log_info "Health check passed"
else
log_error "Health check failed"
exit 1
fi
log_info ""
log_info "Warmup complete (basic mode - bidirectional streaming warmup not available)!"
log_info "The JIT should be warmed for:"
log_info " - gRPC layer and protobuf parsing"
log_info " - Settings loading and management"
log_info ""
log_warn "Note: For full warmup including game creation and command processing,"
log_warn " build and use the Go warmup tool: bazel build //src/main/go/net/eagle0/warmup"
@@ -26,13 +26,6 @@ namespace fs = std::filesystem;
static string rLocation;
auto rloc(const string& execPath) -> string {
// First check for environment variable override for Docker deployment
const char* resourcesPath = getenv("SHARDOK_RESOURCES_PATH");
if (resourcesPath != nullptr) {
return ""; // Return empty so StaticShardokFilesDirectory uses env var directly
}
// Fall back to Bazel runfiles for development
string error;
const std::unique_ptr<Runfiles> runfiles(Runfiles::Create(execPath, &error));
@@ -65,14 +58,10 @@ auto FilesystemUtils::FileExistsAtPath(const string& path) -> bool { return fs::
auto FilesystemUtils::StaticEagle0FilesDirectory() -> string { return "/usr/local/share/eagle0/"; }
auto FilesystemUtils::StaticShardokFilesDirectory() -> string {
const char* resourcesPath = getenv("SHARDOK_RESOURCES_PATH");
if (resourcesPath != nullptr) { return string(resourcesPath) + "/"; }
return rLocation + "/src/main/resources/net/eagle0/shardok/";
}
auto FilesystemUtils::MapFilesDirectory() -> string {
const char* mapsPath = getenv("SHARDOK_MAPS_PATH");
if (mapsPath != nullptr) { return string(mapsPath) + "/"; }
return StaticShardokFilesDirectory() + "maps/";
}
@@ -7,7 +7,6 @@
#include <algorithm>
#include <bit>
#include <cstdint>
#include <cstdlib>
#define ITERABLE_BITSET_INDEX_CHECKS false
@@ -9,7 +9,6 @@
#include "MapUtils.hpp"
#include <algorithm>
#include <stdexcept>
static inline std::string StringForKey(
const std::unordered_map<std::string, std::string>& map,
@@ -14,14 +14,6 @@
#include "src/main/cpp/net/eagle0/common/RandomGenerator.hpp"
// A deterministic random generator that returns values from a fixed sequence.
// Used for testing and MCTS simulation where we want specific, predictable outcomes.
//
// Values in the sequence are treated as [0, 1] probabilities that are returned
// by DoubleZeroToOne(). The normal percentile methods (including open-ended
// variants) work as usual, so callers must provide appropriate sequences.
// For example, to get an open-ended low result of -50, provide [0.02, 0.52]
// which produces: initial=2 (triggers open-ended), accumulated=52, final=2-52=-50
class SequenceRandomGenerator : public ::RandomGenerator {
private:
const std::vector<double> sequence;
@@ -9,7 +9,6 @@
#ifndef byte_vector_h
#define byte_vector_h
#include <cstdint>
#include <cstring>
#include <fstream>
#include <sstream>
@@ -5,18 +5,12 @@
#include "AbstractMCTSAI.hpp"
#include <algorithm>
#include <chrono>
#include <fstream>
#include <future>
#include <iomanip>
#include <limits>
#include <mutex>
#include <random>
#include <stdexcept>
#include <thread>
#include "src/main/cpp/net/eagle0/common/mcts/util/TreeIndentUtil.hpp"
namespace shardok::mcts {
AbstractMCTSAI::AbstractMCTSAI(MCTSPlayerId playerId, MCTSConfig config)
@@ -85,9 +79,6 @@ auto AbstractMCTSAI::Search(
LogSearchResults(rootNode.get(), bestChild, result);
}
// Dump tree if explicitly requested via config
if (!config_.debugDumpPath.empty()) { DumpTreeToFile(rootNode.get(), config_.debugDumpPath); }
return result;
}
@@ -95,19 +86,12 @@ auto AbstractMCTSAI::BuildMCTSTree(
const MCTSGameEngine& engine,
const MCTSGameState& initialState,
const std::chrono::steady_clock::time_point deadline) const -> std::unique_ptr<MCTSNode> {
// Clear transposition table for this search
// Maps state hash -> minimum depth, used to detect redundant longer paths
transpositionTable_.clear();
// Create root node
// IMPORTANT: Use the initial state's current player, not playerId_
// node->playerId represents "whose turn it is", not "who we're searching for"
// This is critical for correct player flip tracking
auto root = std::make_unique<MCTSNode>(initialState.clone(), initialState.currentPlayerId(), 0);
// Record root state in transposition table
transpositionTable_[root->stateHash] = root->depth;
// Set whether root is maximizing based on whether current player matches who we're searching
// for
root->isMaximizingPlayer = (initialState.currentPlayerId() == playerId_);
@@ -130,37 +114,6 @@ auto AbstractMCTSAI::BuildMCTSTree(
// Initialize action counter
root->totalActions = rootActions.size();
// CRITICAL: Do at least one expansion before entering the time-bounded loop.
// This ensures we always have at least one child to return, even if the deadline
// has already passed (e.g., due to debugger pause, system load, etc.)
{
auto* selected = MCTSSelection(root.get());
const bool selectedIsRoot = (selected == root.get());
const size_t childrenBeforeExpansion = root->children.size();
if (selected) {
auto* expanded = MCTSExpansion(selected, engine);
const double reward =
MCTSSimulation(engine, *expanded->gameState, playerId_, expanded->playerFlips);
MCTSBackpropagation(expanded, reward, config_.backpropagationPolicy);
}
// Verify we actually have at least one child after the initial expansion
if (root->children.empty()) {
throw MCTSInternalError(
"MCTS BuildMCTSTree: Initial expansion failed to produce any children. "
"totalActions=" +
std::to_string(root->totalActions) +
", selected=" + (selected ? "non-null" : "null") +
", selectedIsRoot=" + (selectedIsRoot ? "true" : "false") +
", childrenBefore=" + std::to_string(childrenBeforeExpansion) +
", childrenAfter=" + std::to_string(root->children.size()) +
", root->CanExpand()=" + (root->CanExpand() ? "true" : "false") +
", root->nextUntriedActionIndex=" +
std::to_string(root->nextUntriedActionIndex));
}
}
std::atomic<int> iterations{0};
if (config_.useMultithreading && config_.numThreads > 1) {
@@ -207,7 +160,7 @@ auto AbstractMCTSAI::BuildMCTSTree(
while (std::chrono::steady_clock::now() < deadline) {
// Selection
auto* selected = MCTSSelection(root.get());
if (!selected) { break; }
if (!selected) break;
// Expansion
auto* expanded = MCTSExpansion(selected, engine);
@@ -237,27 +190,11 @@ auto AbstractMCTSAI::BuildMCTSTree(
auto AbstractMCTSAI::MCTSSelection(MCTSNode* root) const -> MCTSNode* {
MCTSNode* current = root;
while (current->depth < config_.maxTreeDepth) {
// Check expansion FIRST - allows expanding "terminal" nodes that still have
// untried actions (e.g., final round where we need to pick an action)
while (!current->isTerminal && current->depth < config_.maxTreeDepth) {
if (current->CanExpand()) {
return current; // Node has untried actions/outcomes
}
// Only after expansion check: stop if terminal and fully expanded
if (current->isTerminal) {
break; // Terminal and no more actions to try
}
if (!current->children.empty()) {
// Choose child based on node type
if (current->IsChanceNode()) {
// Chance nodes: select outcome proportional to probability
current = current->GetBestChanceChild();
} else {
// Decision nodes: select using UCB1
current = current->GetBestChild(config_.explorationConstant);
}
return current; // Node has untried actions
} else if (!current->children.empty()) {
current = current->GetBestChild(config_.explorationConstant);
if (!current) break;
} else {
break; // Leaf node
@@ -269,109 +206,10 @@ auto AbstractMCTSAI::MCTSSelection(MCTSNode* root) const -> MCTSNode* {
auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine) const
-> MCTSNode* {
// Only skip if we truly can't expand. Allow expansion even if "terminal" as long as
// there are untried actions (e.g., final round where we need to pick an action).
if (!node->CanExpand()) {
if (!node->CanExpand() || node->isTerminal) {
return node; // Nothing to expand
}
// Handle chance node expansion (expanding outcomes)
if (node->IsChanceNode()) {
// Chance nodes expand their outcome children
// This should have been set up when the chance node was created
if (node->outcomeProbabilities.empty()) {
throw MCTSInternalError(
"Chance node has no outcome probabilities - this indicates a bug");
}
const size_t outcomeIndex = node->nextUntriedActionIndex++;
if (outcomeIndex >= node->outcomeProbabilities.size()) {
throw MCTSInternalError(
"Chance node outcomeIndex >= outcomeProbabilities.size() - bug in expansion");
}
// The chance node's action should be the binary action
if (!node->action) {
throw MCTSInternalError("Chance node has no action - this indicates a bug");
}
// Apply the action with the representative roll for this outcome
// Outcome 0 = success, Outcome 1 = failure
// Use the representative roll for this specific outcome
const double representativeRoll = node->outcomeRolls[outcomeIndex];
auto newState = engine.applyAction(*node->gameState, *node->action, representativeRoll);
if (!newState) {
throw MCTSInternalError(
"MCTS expansion: engine.applyAction() returned nullptr for chance node "
"outcome - this indicates a game engine error");
}
// Determine if player changed
const MCTSPlayerId newPlayerId = newState->currentPlayerId();
const bool playerChanged = (newPlayerId != node->playerId);
// Calculate player flips and maximizing status
const int newPlayerFlips = node->playerFlips + (playerChanged ? 1 : 0);
const bool newIsMaximizing = (newPlayerId == playerId_);
// Create outcome child (decision node)
auto outcomeChild = std::make_unique<MCTSNode>(
node->action->clone(),
std::move(newState),
newPlayerId,
node->depth + 1,
outcomeIndex,
newPlayerFlips,
newIsMaximizing,
node->actionWeight); // Inherit action weight from chance node
// Set up outcome child's actions if not terminal
const bool shouldExpand =
!outcomeChild->isTerminal && node->playerFlips <= config_.maxPlayerFlips;
if (shouldExpand) {
const auto childActions = engine.getLegalActions(
*outcomeChild->gameState,
playerId_,
newPlayerFlips,
config_.maxPlayerFlips);
outcomeChild->totalActions = childActions.size();
}
// Calculate scores
outcomeChild->immediateScore = engine.evaluateState(*outcomeChild->gameState, playerId_);
outcomeChild->lookaheadScore = outcomeChild->immediateScore;
// Set parent and add to children
outcomeChild->parent = node;
node->children.push_back(std::move(outcomeChild));
// Update chance node's immediate score to expected value of expanded outcomes
// This corrects the initial value (which incorrectly used parent state) and ensures
// fair UCB comparison with non-chance actions like END_TURN
{
double expectedImmediate = 0.0;
double totalProbability = 0.0;
for (size_t i = 0; i < node->children.size(); i++) {
const double prob = node->outcomeProbabilities[i];
const double childImmediate = node->children[i]->immediateScore;
expectedImmediate += prob * childImmediate;
totalProbability += prob;
}
// Normalize by total probability of expanded outcomes
if (totalProbability > 0.0) {
node->immediateScore = expectedImmediate / totalProbability;
// CRITICAL: Always update lookaheadScore to the expected value.
// Without this, chance nodes keep their initial lookaheadScore from the parent
// state (before the action), while regular actions use the child state (after).
// This gives chance nodes an unfair initial UCB advantage.
node->lookaheadScore = node->immediateScore;
}
}
return node->children.back().get();
}
// Handle decision node expansion (expanding actions)
// Get next action to expand (sequential order)
const size_t actionIndex = node->nextUntriedActionIndex++;
@@ -395,53 +233,9 @@ auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine)
const auto actionWeights = engine.getActionWeights(nodeActions, *node->gameState);
const auto& action = nodeActions[actionIndex];
const double actionWeight =
actionIndex < actionWeights.size() ? actionWeights[actionIndex] : 1.0;
// Check if this action requires a chance node
if (action->requiresChanceNode()) {
// Create intermediate chance node
auto chanceNode = std::make_unique<MCTSNode>(
action->clone(),
node->gameState->clone(), // Chance node has same state as parent
node->playerId,
node->depth + 1,
actionIndex,
node->playerFlips,
node->isMaximizingPlayer,
actionWeight);
chanceNode->nodeType = NodeType::CHANCE;
// Get outcome information from engine
const auto outcomeInfo = engine.getBinaryOutcomeInfo(*node->gameState, *action);
// Set up outcome metadata (2 outcomes for binary actions)
chanceNode->outcomeProbabilities = outcomeInfo.getProbabilities();
chanceNode->outcomeRolls = outcomeInfo.getRepresentativeRolls();
chanceNode->totalActions = 2; // Binary: success and failure
// Chance node immediate score will be computed as expected value during backpropagation
// For now, initialize to parent's score as a reasonable default
chanceNode->immediateScore = engine.evaluateState(*node->gameState, playerId_);
chanceNode->lookaheadScore = chanceNode->immediateScore;
// Set parent and add to children
chanceNode->parent = node;
node->children.push_back(std::move(chanceNode));
// CRITICAL: Immediately expand the first outcome and return that instead.
// If we returned the chance node itself, MCTSSimulation would run on the parent state
// (since chance nodes have parent's gameState), which is wrong. We need to simulate
// from an actual outcome state.
//
// Note: This recursion is bounded because outcome children are decision nodes,
// not chance nodes, so the recursion goes exactly one level deep.
return MCTSExpansion(node->children.back().get(), engine);
}
// Regular (non-chance) action: create decision node directly
auto newState = engine.applyAction(*node->gameState, *action);
if (!newState) {
throw MCTSInternalError(
@@ -468,37 +262,8 @@ auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine)
newIsMaximizing,
actionWeight); // Pass the action weight for prior-weighted UCB
// Check transposition table: mark as redundant if we've reached this state at a shallower depth
// This prevents MCTS from exploring longer paths to the same game state
// Works best with MINIMAX backpropagation (penalty propagates as min/max)
// Also provides benefit with AVERAGING (penalty pulls average down significantly)
const uint64_t childHash = child->stateHash;
auto it = transpositionTable_.find(childHash);
if (it != transpositionTable_.end()) {
const int previousDepth = it->second;
if (child->depth > previousDepth) {
// Longer path to same state - mark as redundant and heavily penalize
// Use -infinity to be unambiguously worse than any legitimate score
child->isRedundant = true;
child->immediateScore = -std::numeric_limits<double>::infinity();
child->lookaheadScore = -std::numeric_limits<double>::infinity();
} else {
// Found shorter or equal path - update table
transpositionTable_[childHash] = child->depth;
}
} else {
// First time seeing this state - record it
transpositionTable_[childHash] = child->depth;
}
// Set up child's untried actions if not terminal and parent hasn't exceeded player flips
// playerFlips counts how many times the player has CHANGED from root
// We expand children of nodes that are within the maxPlayerFlips limit
// maxPlayerFlips=0: same player can take multiple sequential actions
// maxPlayerFlips=1: can explore opponent's immediate responses
const bool shouldExpand = !child->isTerminal && node->playerFlips <= config_.maxPlayerFlips;
if (shouldExpand) {
// Set up child's untried actions if not terminal
if (!child->isTerminal) {
const auto childActions = engine.getLegalActions(
*child->gameState,
playerId_,
@@ -508,11 +273,8 @@ auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine)
}
// Calculate immediate and lookahead scores from root player's perspective
// Skip for redundant nodes (already have penalty scores)
if (!child->isRedundant) {
child->immediateScore = engine.evaluateState(*child->gameState, playerId_);
child->lookaheadScore = child->immediateScore;
}
child->immediateScore = engine.evaluateState(*child->gameState, playerId_);
child->lookaheadScore = child->immediateScore;
// Set parent and add to children
child->parent = node;
@@ -528,22 +290,14 @@ auto AbstractMCTSAI::MCTSSimulation(
const int startingPlayerFlips) const -> double {
if (state.isTerminal()) { return state.score(startingPlayer); }
// If we've already exceeded the simulation horizon, don't simulate - just return immediate
// score This ensures fair comparison: all leaves are evaluated at the same game phase Example:
// maxSimulationFlips=1 means simulate THROUGH opponent's first response (i.e., allow one action
// at playerFlips=1, then stop)
if (startingPlayerFlips > config_.maxSimulationFlips) { return state.score(startingPlayer); }
// Create a mutable copy for simulation
auto currentState = state.clone();
int depth = 0;
int playerFlips = startingPlayerFlips; // Start from the expanded node's flip count
MCTSPlayerId previousPlayer = currentState->currentPlayerId();
// Simulate until we exceed the horizon, hit terminal state, or max depth
// Note: We allow one action AT maxSimulationFlips before stopping
while (!currentState->isTerminal() && depth < config_.maxSimulationDepth &&
playerFlips <= config_.maxSimulationFlips) {
// Simulate until terminal or max depth
while (!currentState->isTerminal() && depth < config_.maxSimulationDepth) {
// Track player changes
const MCTSPlayerId currentPlayer = currentState->currentPlayerId();
if (currentPlayer != previousPlayer) {
@@ -556,7 +310,7 @@ auto AbstractMCTSAI::MCTSSimulation(
*currentState,
playerId_,
playerFlips,
config_.maxSimulationFlips);
config_.maxPlayerFlips);
if (actions.empty()) { break; }
// Determine if current player is maximizing or minimizing
@@ -597,79 +351,28 @@ auto AbstractMCTSAI::MCTSBackpropagation(
node->totalReward += reward;
node->averageReward = node->totalReward / node->visitCount;
// Update lookahead score based on node type and strategy
if (node->IsChanceNode() && !node->children.empty()) {
// Chance nodes: compute expected value (weighted average of outcomes)
// lookaheadScore = sum(probability[i] * childValue[i])
double expectedValue = 0.0;
double totalProbability = 0.0;
int visitedChildCount = 0;
for (size_t i = 0; i < node->children.size(); i++) {
const auto& child = node->children[i];
if (child->visitCount == 0) continue; // Unvisited outcomes don't contribute
const double probability = node->outcomeProbabilities[i];
const double childValue = child->lookaheadScore;
expectedValue += probability * childValue;
totalProbability += probability;
visitedChildCount++;
}
// Use expected value if we have visited outcomes, else use average
if (visitedChildCount > 0) {
// CRITICAL: Normalize by total probability to get correct expected value
// when not all outcomes have been visited yet
if (totalProbability > 0.0 && totalProbability < 1.0) {
// Normalize to account for unvisited outcomes
// This gives the correct expected value among visited outcomes
expectedValue /= totalProbability;
}
node->lookaheadScore = expectedValue;
} else {
// No outcomes visited yet, fall back to average
if (node->visitCount == 1) {
node->lookaheadScore = reward;
} else {
const double alpha = 1.0 / node->visitCount;
node->lookaheadScore = (1.0 - alpha) * node->lookaheadScore + alpha * reward;
}
}
} else if (useMinimaxBackup && !node->children.empty()) {
// Update lookahead score based on strategy
if (useMinimaxBackup && !node->children.empty()) {
// Minimax backup: use best/worst child value for adversarial games
// The operation (MAX or MIN) depends on whose turn it is at THIS node
// - If this node is root player's turn: root chooses MAX (best for root)
// - If this node is opponent's turn: opponent chooses MIN (best for opponent = worst
// for root)
//
// Note: In setup phase, children can have different isMaximizingPlayer values:
// - PLACE_UNIT keeps same player's turn
// - END_PLAYER_SETUP flips to opponent's turn
// So we must use the PARENT node's isMaximizingPlayer, not the child's.
// This is correct when exploring opponent responses
double minmaxValue = node->isMaximizingPlayer ? -std::numeric_limits<double>::max()
: std::numeric_limits<double>::max();
const bool thisNodeIsRootPlayer = node->isMaximizingPlayer;
double minmaxValue = thisNodeIsRootPlayer ? -std::numeric_limits<double>::max()
: std::numeric_limits<double>::max();
int visitedChildCount = 0;
for (const auto& child : node->children) {
if (child->visitCount == 0) continue; // Unvisited children don't contribute
const double childValue = child->lookaheadScore;
visitedChildCount++;
if (thisNodeIsRootPlayer) {
// Root player chooses: take MAX (best for root)
if (node->isMaximizingPlayer) {
minmaxValue = std::max(minmaxValue, childValue);
} else {
// Opponent chooses: take MIN (best for opponent = worst for root)
minmaxValue = std::min(minmaxValue, childValue);
}
}
// Use minimax value if we found any visited children, else use average
if (visitedChildCount > 0) {
if (minmaxValue != (node->isMaximizingPlayer ? -std::numeric_limits<double>::max()
: std::numeric_limits<double>::max())) {
node->lookaheadScore = minmaxValue;
} else {
// No children visited yet, fall back to average
@@ -877,17 +580,12 @@ auto AbstractMCTSAI::LogSearchResults(
return a->visitCount > b->visitCount;
});
// Show all actions if there are <= 10, otherwise top 5
const size_t numToShow = sortedChildren.size() <= 10 ? sortedChildren.size() : 5;
printf("MCTS: Top %zu actions by visits (out of %zu total):\n",
numToShow,
sortedChildren.size());
for (size_t i = 0; i < numToShow; ++i) {
printf("MCTS: Top actions by visits:\n");
for (size_t i = 0; i < std::min(static_cast<size_t>(3), sortedChildren.size()); ++i) {
const auto* child = sortedChildren[i];
printf(" [%zu] visits:%d avgReward:%.2f immediate:%.2f lookahead:%.2f",
printf(" [%zu] visits:%d immediate:%.2f lookahead:%.2f",
i,
child->visitCount,
child->averageReward,
child->immediateScore,
child->lookaheadScore);
@@ -941,43 +639,18 @@ auto AbstractMCTSAI::LogSearchResults(
if (!bestSequence.empty()) {
printf("MCTS: Best sequence from chosen action (final: %.2f):\n", sequenceScore);
int displayedStep = 0;
for (size_t i = 0; i < bestSequence.size(); ++i) {
const auto* node = bestSequence[i];
// Skip outcome nodes (children of chance nodes) - they're displayed with their parent
if (i > 0 && node->parent && node->parent->IsChanceNode()) { continue; }
displayedStep++;
printf(" %d.", displayedStep);
printf(" %zu.", i + 1);
if (node->action) { printf(" %s", node->action->getDescription().c_str()); }
// If this is a chance node, display outcome probabilities and scores
if (node->IsChanceNode() && !node->outcomeProbabilities.empty()) {
printf(" [");
for (size_t j = 0; j < node->outcomeProbabilities.size(); ++j) {
if (j > 0) printf(", ");
const double prob = node->outcomeProbabilities[j] * 100;
// Show lookahead score for each outcome if child exists
if (j < node->children.size() && node->children[j]->visitCount > 0) {
printf("%.0f%%->%.1f", prob, node->children[j]->lookaheadScore);
} else {
printf("%.0f%%->?", prob);
}
}
printf("]");
}
printf(" (visits:%d, immediate:%.2f, lookahead:%.2f)\n",
node->visitCount,
node->immediateScore,
node->lookaheadScore);
// For non-root nodes in the sequence, show what the top alternatives were
// Skip showing alternatives for chance nodes (they have outcome children, not action
// alternatives)
if (i > 0 && node->parent && !node->parent->children.empty() &&
!node->parent->IsChanceNode()) {
// This helps diagnose if opponent moves are being properly explored
if (i > 0 && node->parent && !node->parent->children.empty()) {
// Collect all siblings (including this node) and sort by visit count
std::vector<const MCTSNode*> siblings;
siblings.reserve(node->parent->children.size());
@@ -1008,100 +681,4 @@ auto AbstractMCTSAI::LogSearchResults(
}
}
auto AbstractMCTSAI::DumpTreeToFile(const MCTSNode* root, const std::string& filepath) -> void {
if (!root) return;
std::ofstream out(filepath);
if (!out) {
fprintf(stderr, "Failed to open dump file: %s\n", filepath.c_str());
return;
}
out << "MCTS Tree Dump\n";
out << "==============\n\n";
out << "Root Node:\n";
out << " Visits: " << root->visitCount << "\n";
out << " Immediate Score: " << root->immediateScore << "\n";
out << " Lookahead Score: " << root->lookaheadScore << "\n";
out << " Average Reward: " << root->averageReward << "\n";
out << " Player ID: " << root->playerId << "\n";
out << " Depth: " << root->depth << "\n";
out << " Is Maximizing: " << (root->isMaximizingPlayer ? "true" : "false") << "\n";
out << " State Hash: " << std::hex << root->stateHash << std::dec << "\n";
out << "\n";
if (!root->children.empty()) {
out << "Children:\n";
for (size_t i = 0; i < root->children.size(); ++i) {
const auto& child = root->children[i];
const bool isLast = (i == root->children.size() - 1);
DumpNodeRecursive(child.get(), out, 1, isLast);
}
}
out << "\n=== End of Tree Dump ===\n";
out.close();
printf("MCTS: Tree dumped to %s\n", filepath.c_str());
}
auto AbstractMCTSAI::DumpNodeRecursive(
const MCTSNode* node,
std::ostream& out,
const int indentLevel,
const bool isLastChild) -> void {
if (!node) return;
// Create indent string
const std::string indent = ::mcts::util::BuildTreeIndent(indentLevel, isLastChild);
// Write node information
out << indent;
// Show node type for chance nodes
if (node->IsChanceNode()) { out << "[CHANCE] "; }
if (node->action) {
out << node->action->getDescription();
} else {
out << "[ROOT]";
}
out << " (visits:" << node->visitCount;
out << ", immediate:" << std::fixed << std::setprecision(2) << node->immediateScore;
out << ", lookahead:" << node->lookaheadScore;
out << ", avgReward:" << node->averageReward;
out << ", weight:" << node->actionWeight;
out << ", depth:" << node->depth;
out << ", flips:" << node->playerFlips;
out << ", player:" << node->playerId;
out << ", max:" << (node->isMaximizingPlayer ? "T" : "F");
if (node->isRedundant) { out << ", REDUNDANT"; }
if (node->isTerminal) { out << ", TERMINAL"; }
out << ")\n";
// Show outcome probabilities and rolls for chance nodes
if (node->IsChanceNode() && !node->outcomeProbabilities.empty()) {
const std::string outcomeIndent = ::mcts::util::ConvertBranchToContinuation(indent);
out << outcomeIndent << " Outcomes: ";
for (size_t i = 0; i < node->outcomeProbabilities.size(); ++i) {
if (i > 0) out << ", ";
out << "[" << i << "] p=" << std::fixed << std::setprecision(3)
<< node->outcomeProbabilities[i];
if (i < node->outcomeRolls.size()) {
out << " roll=" << std::fixed << std::setprecision(1) << node->outcomeRolls[i];
}
}
out << "\n";
}
// Recursively dump children
if (!node->children.empty()) {
for (size_t i = 0; i < node->children.size(); ++i) {
const auto& child = node->children[i];
const bool isLast = (i == node->children.size() - 1);
DumpNodeRecursive(child.get(), out, indentLevel + 1, isLast);
}
}
}
} // namespace shardok::mcts
@@ -7,7 +7,6 @@
#include <chrono>
#include <memory>
#include <unordered_map>
#include <vector>
#include "MCTSAction.hpp"
@@ -52,11 +51,6 @@ private:
MCTSPlayerId playerId_;
MCTSConfig config_;
// Transposition table: maps state hash -> minimum depth at which state was reached
// Used to detect and penalize longer paths to the same game state
// Cleared at the start of each Search() call
mutable std::unordered_map<uint64_t, int> transpositionTable_;
// Core MCTS algorithm
[[nodiscard]] auto BuildMCTSTree(
const MCTSGameEngine& engine,
@@ -90,14 +84,6 @@ private:
const MCTSNode* rootNode,
const MCTSNode* bestChild,
const SearchResult& result) -> void;
// Debug tree dumping
static auto DumpTreeToFile(const MCTSNode* root, const std::string& filepath) -> void;
private:
static auto
DumpNodeRecursive(const MCTSNode* node, std::ostream& out, int indentLevel, bool isLastChild)
-> void;
};
} // namespace mcts
@@ -86,7 +86,6 @@ cc_library(
":mcts_game_state",
":mcts_node",
":mcts_types",
"//src/main/cpp/net/eagle0/common/mcts/util:tree_indent_util",
],
)
@@ -27,11 +27,6 @@ public:
// Check if two actions are equivalent
[[nodiscard]] virtual bool equals(const MCTSAction& other) const = 0;
// Check if this action requires a chance node (binary success/failure outcome)
// Examples: START_FIRE, RAISE_DEAD, EXTINGUISH_FIRE
// If true, the game engine should provide outcome probabilities
[[nodiscard]] virtual bool requiresChanceNode() const = 0;
};
} // namespace mcts
@@ -16,50 +16,15 @@
namespace shardok {
namespace mcts {
// Information about chance outcomes (supports both binary and multi-outcome)
struct ChanceOutcomeInfo {
std::vector<double> probabilities; // Probability of each outcome (must sum to 1.0)
std::vector<double> rolls; // Roll values for each outcome
// Factory for binary success/failure outcomes (e.g., START_FIRE)
[[nodiscard]] static ChanceOutcomeInfo binary(double successProbability) {
// -100: triggers open-ended low sequence, succeeds against any threshold
// 150: triggers open-ended high sequence, fails against any threshold
return {{successProbability, 1.0 - successProbability}, {-100.0, 150.0}};
}
// Factory for multi-outcome with fixed seeds (e.g., END_TURN)
// Uses uniformly distributed roll values to sample different random outcomes
[[nodiscard]] static ChanceOutcomeInfo multiOutcome(int numOutcomes) {
std::vector<double> probs(numOutcomes, 1.0 / numOutcomes);
std::vector<double> rollValues;
rollValues.reserve(numOutcomes);
// Spread rolls across the percentile range: 10, 30, 50, 70, 90 for 5 outcomes
for (int i = 0; i < numOutcomes; ++i) {
rollValues.push_back(10.0 + (80.0 * i) / (numOutcomes - 1));
}
return {probs, rollValues};
}
[[nodiscard]] const std::vector<double>& getRepresentativeRolls() const { return rolls; }
[[nodiscard]] const std::vector<double>& getProbabilities() const { return probabilities; }
};
// Backward compatibility alias
using BinaryOutcomeInfo = ChanceOutcomeInfo;
// Abstract interface for game engines
class MCTSGameEngine {
public:
virtual ~MCTSGameEngine() = default;
// Apply an action to a state and return the resulting state
// If deterministicRoll is provided (0.0-100.0), use that for any random outcomes
[[nodiscard]] virtual std::unique_ptr<MCTSGameState> applyAction(
const MCTSGameState& state,
const MCTSAction& action,
double deterministicRoll = -1.0) const = 0;
const MCTSAction& action) const = 0;
// Apply an action to a mutable state in-place (for efficient simulation)
// Default: clone, apply, and move the result back
@@ -146,13 +111,6 @@ public:
(void)state; // Suppress unused parameter warning
return filteredIndex;
}
// Get binary outcome information for an action that requires a chance node
// Only called for actions where action.requiresChanceNode() returns true
// Returns success probability for binary success/failure actions
[[nodiscard]] virtual BinaryOutcomeInfo getBinaryOutcomeInfo(
const MCTSGameState& state,
const MCTSAction& action) const = 0;
};
} // namespace mcts
@@ -17,16 +17,8 @@
namespace shardok {
namespace mcts {
// Node type for MCTS tree
enum class NodeType {
DECISION, // Player chooses an action (standard MCTS node)
CHANCE // Nature determines outcome (for probabilistic actions)
};
// Abstract MCTS Node structure
struct MCTSNode {
// Node type
NodeType nodeType = NodeType::DECISION;
// Action information
std::unique_ptr<MCTSAction> action; // The action that led to this node (null for root)
size_t actionIndex = SIZE_MAX; // Index in the original actions array (SIZE_MAX for root)
@@ -51,10 +43,6 @@ struct MCTSNode {
size_t totalActions = 0; // Total number of available actions
MCTSNode* parent = nullptr;
// Chance node specific fields (only used when nodeType == CHANCE)
std::vector<double> outcomeProbabilities; // Probability of each outcome
std::vector<double> outcomeRolls; // Representative roll for each outcome
// Game context
MCTSPlayerId playerId;
int depth = 0;
@@ -148,40 +136,6 @@ struct MCTSNode {
// Check if this node can be expanded
[[nodiscard]] bool CanExpand() const { return nextUntriedActionIndex < totalActions; }
// Check if this is a chance node
[[nodiscard]] bool IsChanceNode() const { return nodeType == NodeType::CHANCE; }
// Check if this is a decision node
[[nodiscard]] bool IsDecisionNode() const { return nodeType == NodeType::DECISION; }
// Get best child from chance node (probability-weighted selection)
// For chance nodes, we want to explore outcomes proportionally to their probability
[[nodiscard]] MCTSNode* GetBestChanceChild() const {
if (children.empty() || !IsChanceNode()) return nullptr;
// Find the outcome that is most under-explored relative to its probability
// Expected visits for outcome i: total_visits * probability[i]
// Actual visits: child[i]->visitCount
// Deficit: expected - actual
size_t bestIndex = 0;
double bestDeficit = -std::numeric_limits<double>::max();
for (size_t i = 0; i < children.size(); i++) {
if (!children[i] || children[i]->isRedundant) continue;
const double expectedVisits = visitCount * outcomeProbabilities[i];
const double actualVisits = static_cast<double>(children[i]->visitCount);
const double deficit = expectedVisits - actualVisits;
if (deficit > bestDeficit) {
bestDeficit = deficit;
bestIndex = i;
}
}
return children[bestIndex].get();
}
// Get best child based on UCB1
[[nodiscard]] MCTSNode* GetBestChild(const double explorationConstant) const {
if (children.empty()) return nullptr;
@@ -44,14 +44,8 @@ struct MCTSConfig {
int numThreads = 16; // Number of threads for parallel MCTS
MCTSSimulationPolicy simulationPolicy = MCTSSimulationPolicy::BEST_IMMEDIATE;
MCTSBackpropagationPolicy backpropagationPolicy = MCTSBackpropagationPolicy::AVERAGING;
int maxPlayerFlips = 0; // Maximum number of player changes for tree expansion
// (0 = expand through current player's turn only,
// 1 = expand through opponent's first response, etc.)
int maxSimulationFlips = 0; // Maximum player flips for leaf evaluation
// When evaluating a leaf at playerFlips < maxSimulationFlips,
// simulate forward to this phase for fair comparison
// (default 0 = evaluate leaves as-is, backward compatible)
std::string debugDumpPath = ""; // If non-empty, dump MCTS tree to this file path
int maxPlayerFlips = 0; // Maximum number of player changes to explore (0 = stop at first
// flip, 1 = explore through opponent's response, etc.)
};
} // namespace mcts
@@ -1,8 +0,0 @@
load("@rules_cc//cc:defs.bzl", "cc_library")
cc_library(
name = "tree_indent_util",
srcs = ["TreeIndentUtil.cpp"],
hdrs = ["TreeIndentUtil.hpp"],
visibility = ["//visibility:public"],
)
@@ -1,53 +0,0 @@
//
// Utility functions for processing tree indentation with UTF-8 box drawing characters
//
#include "TreeIndentUtil.hpp"
namespace mcts::util {
namespace {
// Box drawing characters for tree visualization
constexpr const char* kBranch = "\xE2\x94\x9C"; // ├
constexpr const char* kCorner = "\xE2\x94\x94"; // └
constexpr const char* kVertical = "\xE2\x94\x82"; // │
constexpr const char* kHorizontal = "\xE2\x94\x80"; // ─
} // namespace
std::string BuildTreeIndent(int indentLevel, bool isLastChild) {
std::string indent;
for (int i = 0; i < indentLevel; ++i) {
if (i == indentLevel - 1) {
indent += isLastChild ? kCorner : kBranch;
indent += kHorizontal;
indent += " ";
} else {
indent += " ";
}
}
return indent;
}
std::string ConvertBranchToContinuation(const std::string& indent) {
std::string result = indent;
const std::string replacement = std::string(kVertical) + " ";
// Replace ├ and └ with │
size_t pos = 0;
while ((pos = result.find(kBranch, pos)) != std::string::npos) {
result.replace(pos, 3, replacement); // UTF-8 chars are 3 bytes
pos += replacement.size();
}
pos = 0;
while ((pos = result.find(kCorner, pos)) != std::string::npos) {
result.replace(pos, 3, replacement);
pos += replacement.size();
}
return result;
}
} // namespace mcts::util
@@ -1,22 +0,0 @@
//
// Utility functions for processing tree indentation with UTF-8 box drawing characters
//
#ifndef EAGLE0_TREE_INDENT_UTIL_HPP
#define EAGLE0_TREE_INDENT_UTIL_HPP
#include <string>
namespace mcts::util {
// Builds tree indentation string for a node at a given depth
// Returns string like " ├─ " or " └─ " with proper spacing
std::string BuildTreeIndent(int indentLevel, bool isLastChild);
// Converts tree branch characters (├ and └) to continuation lines (│) for sub-content
// This preserves the tree structure when displaying additional info below a node
std::string ConvertBranchToContinuation(const std::string& indent);
} // namespace mcts::util
#endif // EAGLE0_TREE_INDENT_UTIL_HPP
@@ -14,7 +14,6 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/library:engine",
"//src/main/cpp/net/eagle0/shardok/server:eagle_interface_grpc_server",
"//src/main/cpp/net/eagle0/shardok/server:server_configuration",
"//src/main/cpp/net/eagle0/shardok/server:token_auth",
"//src/main/protobuf/net/eagle0/common:common_unit_cc_proto",
"@grpc//:grpc++",
],
@@ -27,7 +27,7 @@ auto AIAttackerStrategySelector::BestAttackerStrategy(
const BattalionTypeGetter& battalionTypeGetter,
ActionPoints braveWaterCost,
const AIWaterCrossingCommandChooser& waterCrossingCommandChooser,
const CommandListSPtr& /*availableCommands*/) -> AIStrategy {
const vector<CommandProto>& /*availableCommands*/) -> AIStrategy {
uint32_t attackerUnitCount = 0;
int defenderOccupiedCriticalTileCount = 0;
bool canFlee = false;
@@ -107,23 +107,6 @@ auto AIAttackerStrategySelector::BestAttackerStrategy(
alCache,
battalionTypeGetter,
braveWaterCost));
} else if (!defenderPositions.empty()) {
// Defenders exist but none are on castles - they're scattering/fleeing.
// Chase them down rather than holding empty castles, since eliminating
// all defenders also wins the battle via LAST_PLAYER_STANDING.
chosenStrategy = AttackUnitsStrategy(GenerateTargetPriorities(
Occupants(
*gameState->units(),
gameState->hex_map()->row_count(),
gameState->hex_map()->column_count()),
gameState->hex_map(),
defenderPositions,
attackerPid,
attackerUnits,
apdCache,
alCache,
battalionTypeGetter,
braveWaterCost));
} else {
chosenStrategy = HoldCastlesStrategy;
}
@@ -10,7 +10,6 @@
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIWaterCrossingCommandChooser.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCommand.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
@@ -28,7 +27,7 @@ public:
const BattalionTypeGetter& battalionTypeGetter,
ActionPoints braveWaterCost,
const AIWaterCrossingCommandChooser& waterCrossingCommandChooser,
const CommandListSPtr& availableCommands) -> AIStrategy;
const vector<CommandProto>& availableCommands) -> AIStrategy;
};
} // namespace shardok
@@ -14,6 +14,7 @@
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
namespace shardok {
@@ -23,6 +24,7 @@ class AIScoreCalculator;
class ShardokEngine;
using ScoreValue = double;
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
using CommandType = net::eagle0::shardok::common::CommandType;
using BattalionTypeGetter = std::function<BattalionTypeSPtr(BattalionTypeId)>;
@@ -7,7 +7,6 @@
#include <algorithm>
#include "src/main/cpp/net/eagle0/shardok/library/BattalionType.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokException.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexCubeUtils.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
@@ -144,16 +143,15 @@ bool AICommandFilter::IsWastefulAction(
if (!isDefender) {
// Attackers: Only allow fire if the target location is on or adjacent to an enemy
const int targetRow = cmd.GetTargetRow();
const int targetCol = cmd.GetTargetColumn();
if (targetRow < 0 || targetCol < 0) {
throw ShardokInternalErrorException(
"START_FIRE_COMMAND missing required target information");
const auto cmdProto = cmd.GetCommandProto();
if (!cmdProto.has_target()) {
return true; // Can't analyze without target info
}
const Coords fireLocation(
static_cast<int8_t>(targetRow),
static_cast<int8_t>(targetCol));
const auto& targetCoords = cmdProto.target();
const Coords fireLocation{
static_cast<int8_t>(targetCoords.row()),
static_cast<int8_t>(targetCoords.column())};
// Check if any enemy is on the fire location or adjacent to it
bool enemyNearFireLocation = false;
@@ -188,12 +186,13 @@ bool AICommandFilter::IsWastefulAction(
if (!isDefender) {
// Attackers: Only allow fortify if within 3 hexes of enemies or castles
const int unitId = cmd.GetActorUnitId();
if (unitId < 0) {
throw ShardokInternalErrorException(
"FORTIFY_COMMAND missing required actor information");
const auto cmdProto = cmd.GetCommandProto();
if (!cmdProto.has_actor()) {
return true; // Can't analyze without actor info
}
const auto unitId = cmdProto.actor().value();
// Get the acting unit directly by ID
const Unit* actingUnit = gameState->units()->Get(unitId);
// verify the unit is still active
@@ -250,18 +249,16 @@ bool AICommandFilter::IsWastefulAction(
// These actions can fail, so we need high confidence of benefit (8+ action points
// saved)
const int unitId = cmd.GetActorUnitId();
const int targetRow = cmd.GetTargetRow();
const int targetCol = cmd.GetTargetColumn();
if (unitId < 0 || targetRow < 0 || targetCol < 0) {
throw ShardokInternalErrorException(
"BUILD_BRIDGE/FREEZE_WATER_COMMAND missing required actor or target "
"information");
const auto cmdProto = cmd.GetCommandProto();
if (!cmdProto.has_actor() || !cmdProto.has_target()) {
return true; // Can't analyze without full command info
}
const Coords waterLocation(
static_cast<int8_t>(targetRow),
static_cast<int8_t>(targetCol));
const auto unitId = cmdProto.actor().value();
const auto& targetCoords = cmdProto.target();
const Coords waterLocation{
static_cast<int8_t>(targetCoords.row()),
static_cast<int8_t>(targetCoords.column())};
// Get the acting unit directly by ID
const Unit* actingUnit = gameState->units()->Get(unitId);
@@ -356,16 +353,15 @@ bool AICommandFilter::IsWastefulAction(
case CommandType::REPAIR_COMMAND: {
// Repair filtering - filter repairs with high integrity targets
// Note: RepairCommandFactory already filters enemy-occupied targets
const int targetRow = cmd.GetTargetRow();
const int targetCol = cmd.GetTargetColumn();
if (targetRow < 0 || targetCol < 0) {
throw ShardokInternalErrorException(
"REPAIR_COMMAND missing required target information");
const auto cmdProto = cmd.GetCommandProto();
if (!cmdProto.has_target()) {
return true; // Can't analyze without target info
}
const Coords repairLocation(
static_cast<int8_t>(targetRow),
static_cast<int8_t>(targetCol));
const auto& targetCoords = cmdProto.target();
const Coords repairLocation{
static_cast<int8_t>(targetCoords.row()),
static_cast<int8_t>(targetCoords.column())};
// Check terrain modifiers at target location
const auto* terrain = GetTerrain(gameState->hex_map(), repairLocation);
@@ -388,16 +384,15 @@ bool AICommandFilter::IsWastefulAction(
case CommandType::EXTINGUISH_FIRE_COMMAND: {
// Extinguish fire filtering - don't extinguish fires on enemy-occupied tiles
const int targetRow = cmd.GetTargetRow();
const int targetCol = cmd.GetTargetColumn();
if (targetRow < 0 || targetCol < 0) {
throw ShardokInternalErrorException(
"EXTINGUISH_FIRE_COMMAND missing required target information");
const auto cmdProto = cmd.GetCommandProto();
if (!cmdProto.has_target()) {
return true; // Can't analyze without target info
}
const Coords fireLocation(
static_cast<int8_t>(targetRow),
static_cast<int8_t>(targetCol));
const auto& targetCoords = cmdProto.target();
const Coords fireLocation{
static_cast<int8_t>(targetCoords.row()),
static_cast<int8_t>(targetCoords.column())};
// Check if any enemy occupies the fire location - let them burn!
std::vector<PlayerId> allyPids; // Empty for now - assume 2-player game
@@ -429,17 +424,17 @@ bool AICommandFilter::IsWastefulMovement(
return false; // Don't filter defender movement or when close to enemies
}
// Get unit and target information directly from command
const int unitId = cmd.GetActorUnitId();
const int targetRow = cmd.GetTargetRow();
const int targetCol = cmd.GetTargetColumn();
// Get the command proto to access unit and target information
const auto cmdProto = cmd.GetCommandProto();
// Check if we have the required information
if (unitId < 0 || targetRow < 0 || targetCol < 0) {
throw ShardokInternalErrorException(
"MOVE_COMMAND missing required actor or target information");
if (!cmdProto.has_actor() || !cmdProto.has_target()) {
return false; // Can't analyze without unit and target info
}
const auto unitId = cmdProto.actor().value();
const auto& targetCoords = cmdProto.target();
// Get the acting unit directly by ID
const Unit* actingUnit = gameState->units()->Get(unitId);
// Verify the unit is still active
@@ -455,7 +450,9 @@ bool AICommandFilter::IsWastefulMovement(
}
const auto& currentCoords = actingUnit->location();
const Coords targetCoordsFlat(static_cast<int8_t>(targetRow), static_cast<int8_t>(targetCol));
const Coords targetCoordsFlat{
static_cast<int8_t>(targetCoords.row()),
static_cast<int8_t>(targetCoords.column())};
// Get action point distances for this unit's battalion type
const auto& battType = battalionTypeGetter(actingUnit->battalion().type());
@@ -14,6 +14,7 @@
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
namespace shardok {
@@ -15,9 +15,9 @@
namespace shardok {
auto AIFleeDecisionCalculator::GetFleeCommandIndex(
const CommandList::const_iterator& fleeCommand,
const CommandListSPtr& availableCommands) -> size_t {
return static_cast<size_t>(std::distance(availableCommands->begin(), fleeCommand));
const vector<CommandProto>::const_iterator& fleeCommand,
const vector<CommandProto>& availableCommands) -> size_t {
return static_cast<size_t>(std::distance(availableCommands.begin(), fleeCommand));
}
auto AIFleeDecisionCalculator::EstimateCombatSuccess(
@@ -134,14 +134,14 @@ auto AIFleeDecisionCalculator::EstimateCombatSuccess(
auto AIFleeDecisionCalculator::EvaluateFleeVsFight(
PlayerId playerId,
const GameStateW& guessedState,
const CommandListSPtr& availableCommands,
const CommandList::const_iterator& fleeCommand,
const vector<CommandProto>& availableCommands,
const vector<CommandProto>::const_iterator& fleeCommand,
int maxRounds,
int minimumFleeOddsThreshold,
int desperateFleeThreshold,
bool enableDebugLogging) -> FleeDecision {
// Get flee success odds
const int fleeSuccessChance = (*fleeCommand)->GetOddsPercentile();
const int fleeSuccessChance = fleeCommand->odds().success_chance();
if (enableDebugLogging) {
printf("AI FinalRound: Evaluating flee (odds=%d%%)...\n", fleeSuccessChance);
@@ -9,11 +9,13 @@
#ifndef AIFleeDecisionCalculator_hpp
#define AIFleeDecisionCalculator_hpp
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCommand.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
namespace shardok {
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
class AIFleeDecisionCalculator {
public:
// Configuration for flee decision thresholds
@@ -33,8 +35,8 @@ public:
[[nodiscard]] static auto EvaluateFleeVsFight(
PlayerId playerId,
const GameStateW& guessedState,
const CommandListSPtr& availableCommands,
const CommandList::const_iterator& fleeCommand,
const vector<CommandProto>& availableCommands,
const vector<CommandProto>::const_iterator& fleeCommand,
int maxRounds,
int minimumFleeOddsThreshold,
int desperateFleeThreshold,
@@ -57,8 +59,8 @@ public:
private:
// Helper to get flee command index
[[nodiscard]] static auto GetFleeCommandIndex(
const CommandList::const_iterator& fleeCommand,
const CommandListSPtr& availableCommands) -> size_t;
const vector<CommandProto>::const_iterator& fleeCommand,
const vector<CommandProto>& availableCommands) -> size_t;
};
} // namespace shardok
@@ -4,7 +4,6 @@
#include "AIHeuristicWeighting.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokException.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistances.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
@@ -15,10 +14,7 @@ using Coords = net::eagle0::shardok::storage::fb::Coords;
using ProtoCoords = net::eagle0::shardok::common::Coords;
double AIHeuristicWeighting::GetCommandWeight(
const CommandType commandType,
const UnitId actorUnitId,
const PlayerId actorPlayerId,
const Coords& targetCoords,
const net::eagle0::shardok::api::CommandDescriptor& command,
const GameStateW& state,
const CoordsSet& castleCoords,
const APDCache* apdCache,
@@ -30,9 +26,9 @@ double AIHeuristicWeighting::GetCommandWeight(
const auto* hexMap = state->hex_map();
const auto* units = state->units();
const bool hasTarget = (targetCoords.row() >= 0 && targetCoords.column() >= 0);
const auto actorPlayerId = command.player();
switch (commandType) {
switch (command.type()) {
// === HIGH VALUE OFFENSIVE (10.0) ===
// Ranged attacks - very valuable, typically available when in range
case CommandType::ARCHERY_COMMAND: return 20.0;
@@ -41,27 +37,20 @@ double AIHeuristicWeighting::GetCommandWeight(
// Area/tactical spells - high impact
case CommandType::METEOR_START_COMMAND: {
// METEOR_START doesn't have a target - it's based on actor location
if (hasTarget) {
throw ShardokInternalErrorException(
"METEOR_START_COMMAND should not have target coordinates");
}
// High weight per enemy unit at or adjacent to target
if (!command.has_target()) return 0.0; // Default if no target info
// Get actor's location
const auto* actorUnit = units->Get(actorUnitId);
if (!actorUnit) {
throw ShardokInternalErrorException(
"METEOR_START_COMMAND actor unit not found in game state");
}
const Coords& actorLocation = actorUnit->location();
const Coords targetCoords(command.target().row(), command.target().column());
int enemyCount = 0;
// Count enemies within meteor range (3 hexes) of actor location
constexpr int METEOR_RANGE = 3;
const auto tilesInRange = TilesWithinDistance(hexMap, actorLocation, METEOR_RANGE);
for (const auto& tileCoords : tilesInRange) {
if (const auto* unit = Occupant(units, tileCoords)) {
// Count enemies at target
if (const auto* targetUnit = Occupant(units, targetCoords)) {
if (targetUnit->player_id() != actorPlayerId) { enemyCount++; }
}
// Count enemies adjacent to target
for (const auto& neighbor : HexMapUtils::GetAdjacentTiles(hexMap, targetCoords)) {
if (const auto* unit = Occupant(units, neighbor.coords)) {
if (unit->player_id() != actorPlayerId) { enemyCount++; }
}
}
@@ -71,12 +60,9 @@ double AIHeuristicWeighting::GetCommandWeight(
case CommandType::METEOR_TARGET_COMMAND: {
// High weight per enemy unit at or adjacent to target
if (!hasTarget) {
throw ShardokInternalErrorException(
"METEOR_TARGET_COMMAND requires target coordinates for heuristic "
"weighting");
}
if (!command.has_target()) return 6.0; // Default if no target info
const Coords targetCoords(command.target().row(), command.target().column());
int enemyCount = 0;
// Count enemies at target
@@ -100,17 +86,15 @@ double AIHeuristicWeighting::GetCommandWeight(
// Fire on enemy (context-dependent)
case CommandType::START_FIRE_COMMAND: {
// High if enemy at target, low otherwise
if (!hasTarget) {
throw ShardokInternalErrorException(
"START_FIRE_COMMAND requires target coordinates for heuristic weighting");
}
if (!command.has_target()) return 3.0; // Default
const Coords targetCoords(command.target().row(), command.target().column());
if (const auto* targetUnit = Occupant(units, targetCoords)) {
if (targetUnit->player_id() != actorPlayerId) {
return 10.0; // Enemy at target - high value
}
}
return 1.0; // No enemy - low value but still valid
return 1.0; // No enemy - low value
}
// === MEDIUM-HIGH OFFENSIVE (5.0-7.0) ===
@@ -125,8 +109,9 @@ double AIHeuristicWeighting::GetCommandWeight(
case CommandType::REDUCE_COMMAND: {
// High if enemy at target, zero otherwise
if (!hasTarget) return 0.0;
if (!command.has_target()) return 0.0;
const Coords targetCoords(command.target().row(), command.target().column());
if (const auto* targetUnit = Occupant(units, targetCoords)) {
if (targetUnit->player_id() != actorPlayerId) {
return 10.0; // Enemy at target - very high value
@@ -142,13 +127,10 @@ double AIHeuristicWeighting::GetCommandWeight(
}
// Attackers: weight based on distance improvement towards castle
if (!hasTarget) {
throw ShardokInternalErrorException(
"MOVE_COMMAND requires target coordinates for heuristic weighting");
}
if (!command.has_target()) return 4.0; // Default if no target
// Get actor unit to determine battalion type and start position
const auto* actorUnit = units->Get(actorUnitId);
const auto* actorUnit = units->Get(command.actor().value());
if (!actorUnit) return 4.0; // Default if can't find actor
// Get battalion type for distance calculation
@@ -170,7 +152,7 @@ double AIHeuristicWeighting::GetCommandWeight(
}
// Calculate minimum distance from end to any castle
const Coords& endCoords = targetCoords;
const Coords endCoords(command.target().row(), command.target().column());
auto minEndDistance = ActionPointDistances::IMPOSSIBLE;
for (const auto& castleCoord : castleCoords) {
const auto dist = apd->Distance(endCoords, castleCoord);
@@ -199,18 +181,15 @@ double AIHeuristicWeighting::GetCommandWeight(
// === LOW VALUE DEFENSIVE/UTILITY (1.0-2.0) ===
case CommandType::EXTINGUISH_FIRE_COMMAND: {
// High if friendly at target, low otherwise
if (!hasTarget) {
throw ShardokInternalErrorException(
"EXTINGUISH_FIRE_COMMAND requires target coordinates for heuristic "
"weighting");
}
if (!command.has_target()) return 2.0; // Default
const Coords targetCoords(command.target().row(), command.target().column());
if (const auto* targetUnit = Occupant(units, targetCoords)) {
if (targetUnit->player_id() == actorPlayerId) {
return 8.0; // Friendly at target - high value
}
}
return 1.0; // No friendly - low value but still valid
return 1.0; // No friendly - low value
}
case CommandType::UNIT_REST_COMMAND: return 1.5;
@@ -222,6 +201,7 @@ double AIHeuristicWeighting::GetCommandWeight(
case CommandType::RELEASE_UNIT_COMMAND: return 0.0;
case CommandType::REINFORCE_COMMAND: return 10.0;
case CommandType::MANAGE_PRISONER: return 1.0;
// === ZERO WEIGHT - NEVER SELECT (0.0) ===
// Explicitly bad actions
@@ -8,6 +8,7 @@
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-redundant-constexpr-static-def"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
#pragma clang diagnostic pop
@@ -24,10 +25,7 @@ public:
// Get weight for a command using fast heuristics with game context
// Returns weight >= 0.0, where 0.0 means "never select" and higher is more likely
static double GetCommandWeight(
net::eagle0::shardok::common::CommandType commandType,
UnitId actorUnitId,
PlayerId actorPlayerId,
const Coords& targetCoords,
const net::eagle0::shardok::api::CommandDescriptor& command,
const GameStateW& state,
const CoordsSet& castleCoords,
const APDCache* apdCache,
@@ -25,8 +25,7 @@ auto CalculateTimeBudget(
const PlayerId playerId,
const GameSettingsSPtr &settings,
const GameStateW &state,
const size_t numCommands,
const bool isAllAiBattle) -> AITimeBudget {
const size_t numCommands) -> AITimeBudget {
const auto settingsGetter = settings->GetGetter();
const auto castleCoords = AllCastleCoords(state->hex_map());
@@ -35,10 +34,8 @@ auto CalculateTimeBudget(
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP;
// Get maximum budget cap from settings (in seconds)
// For all-AI battles, use the faster budget limit
const double maxBudgetSeconds =
isAllAiBattle ? settingsGetter.Backing().all_ai_battle_time_budget_maximum()
: settingsGetter.Backing().lookahead_time_budget_maximum_seconds();
settingsGetter.Backing().lookahead_time_budget_maximum_seconds();
const double maxBudgetMs = maxBudgetSeconds * 1000.0;
// During setup, use the setup-specific time budget
@@ -116,15 +113,6 @@ auto CalculateTimeBudget(
const auto clampedBudgetMs = std::clamp(budgetMs, 200.0, maxBudgetMs);
const auto remainingBudget = std::chrono::milliseconds(static_cast<int64_t>(clampedBudgetMs));
// TEMPORARY DEBUG OUTPUT
printf("[DEBUG CalculateTimeBudget] numCommands=%zu, msPerCommand=%.2f, budgetMs=%.2f, "
"clampedBudgetMs=%.2f, isClose=%d\n",
numCommands,
msPerCommand,
budgetMs,
clampedBudgetMs,
isClose);
// Get minimum depth requirement
const size_t minDepth = settingsGetter.Backing().min_lookahead_turns();
@@ -38,13 +38,11 @@ struct AITimeBudget {
// Calculate time budget based on proximity to enemies and castles
// Time budget is calculated dynamically based on number of available commands:
// budget = msPerCommand × numCommands (clamped to 200-5000ms)
// If isAllAiBattle is true, uses allAiBattleTimeBudgetMaximum instead of the normal maximum.
auto CalculateTimeBudget(
PlayerId playerId,
const GameSettingsSPtr &settings,
const GameStateW &state,
size_t numCommands,
bool isAllAiBattle) -> AITimeBudget;
size_t numCommands) -> AITimeBudget;
} // namespace shardok
@@ -17,10 +17,9 @@ using std::end;
using std::shared_ptr;
constexpr double kProfessionValue = 200;
constexpr double kVigorScoreMultiplier = 5.0;
constexpr double kCastleMultiplierBonus = 1.0;
constexpr double kOnFireMultiplier = 0.25;
constexpr double kAdjacentFireMultiplier = 0.80;
constexpr double kAdjacentFireMultiplier = 0.99;
constexpr double kOnIceMultiplier = 0.25;
constexpr double kMeteorStartInRangeValue = 50;
constexpr double kMeteorDirectTargetingEnemy = 2;
@@ -64,8 +63,7 @@ auto ContextFreeUnitValue(const Unit *unit) -> ScoreValue {
4.0;
}
const double vigorValue =
unit->has_attached_hero() ? unit->attached_hero().vigor() * kVigorScoreMultiplier : 0.0;
const double vigorValue = unit->has_attached_hero() ? unit->attached_hero().vigor() : 0.0;
double battalionTypeMultiplier = 1.0;
switch (unit->battalion().type()) {
@@ -357,7 +355,9 @@ auto UnitValue(
kCastleMultiplierBonus * (terrain->modifier().castle().integrity() + 25) / 100.0;
}
double onFireMultiplier = 1.0;
if (terrain->modifier().fire().present()) { onFireMultiplier *= kOnFireMultiplier; }
if (terrain->modifier().fire().present() && (isAttacker || attackerWantsCastles)) {
onFireMultiplier *= kOnFireMultiplier;
}
{
for (const auto adjacentCoords = HexMapUtils::GetAdjacentCoords(map, location);
const auto &c : adjacentCoords) {
@@ -20,9 +20,8 @@ auto UnitIdsRequiringWaterCrossing(
fb::HexMapW mapCopy = fb::CopyHexMap(gameState->hex_map());
for (uint32_t index = 0; index < mapCopy->terrain()->size(); index++) {
if (IsWater(mapCopy->terrain()->Get(index)->type())) continue;
// const_cast is safe because we own the mutable buffer (mapCopy)
const_cast<net::eagle0::shardok::storage::fb::Terrain *>(
mapCopy->mutable_terrain()->GetMutableObject(index))
mapCopy->mutable_terrain()
->GetMutableObject(index)
->mutable_modifier()
.mutable_fire()
.mutate_present(false);
@@ -165,11 +164,16 @@ auto WaterCrossingTiles(
if (modifier.ice().present() && !modifier.fire().present()) continue;
// Now try adding a bridge to the tile to see if it helps
// const_cast is safe because we own the mutable buffer (mapCopy)
auto *terr = const_cast<net::eagle0::shardok::storage::fb::Terrain *>(
mapCopy->mutable_terrain()->GetMutableObject(index));
terr->mutable_modifier().mutable_bridge().mutate_present(true);
terr->mutable_modifier().mutable_fire().mutate_present(false);
mapCopy->mutable_terrain()
->GetMutableObject(index)
->mutable_modifier()
.mutable_bridge()
.mutate_present(true);
mapCopy->mutable_terrain()
->GetMutableObject(index)
->mutable_modifier()
.mutable_fire()
.mutate_present(false);
auto hash = ActionPointDistancesCache::GetMapId(mapCopy);
@@ -179,7 +183,11 @@ auto WaterCrossingTiles(
}
// Undo the new bridge for the next iteration of the loop
terr->mutable_modifier().mutable_bridge().mutate_present(false);
mapCopy->mutable_terrain()
->GetMutableObject(index)
->mutable_modifier()
.mutable_bridge()
.mutate_present(false);
}
return returnCoords;
@@ -13,9 +13,11 @@
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
namespace shardok {
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
using GameState = net::eagle0::shardok::storage::fb::GameState;
using Unit = net::eagle0::shardok::storage::fb::Unit;
using ScoreValue = double;
@@ -164,6 +164,7 @@ cc_library(
":ai_unit_score_calculator",
"//src/main/cpp/net/eagle0/shardok/library:engine",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
],
)
@@ -181,6 +182,7 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
"//src/main/cpp/net/eagle0/shardok/library/map:coords_set",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
"//src/main/protobuf/net/eagle0/shardok/common:command_type_cc_proto",
],
)
@@ -204,6 +206,7 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library/map:coords_set",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_cube_utils",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
"//src/main/protobuf/net/eagle0/shardok/common:command_type_cc_proto",
],
)
@@ -226,6 +229,7 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
"//src/main/flatbuffer/net/eagle0/shardok/storage:game_state_cc_fbs",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
"//src/main/protobuf/net/eagle0/shardok/common:command_type_cc_proto",
],
)
@@ -312,6 +316,7 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
],
)
@@ -354,6 +359,7 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
"//src/main/cpp/net/eagle0/shardok/library:engine",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
],
)
@@ -38,7 +38,7 @@ IterativeDeepeningAI::IterativeDeepeningAI(
auto IterativeDeepeningAI::IterativeSearch(
const GameSettingsSPtr& settings,
const GameStateW& state,
const CommandListSPtr& commands,
const std::vector<CommandProto>& commands,
const AITimeBudget& initialBudget) const -> SearchResult {
// Make a mutable copy of the time budget to track remaining time
AITimeBudget timeBudget = initialBudget;
@@ -51,7 +51,7 @@ auto IterativeDeepeningAI::IterativeSearch(
// DEBUG: Clear TT to see if that's causing the suspicious depth reaching
// g_transpositionTable.clear(); // Uncomment to test without cross-search caching
if (commands->empty()) {
if (commands.empty()) {
#if DEBUG_ITERATIVE_DEEPENING_TIMINGS
printf("ID AI: Commands are empty, returning early\n");
#endif
@@ -75,9 +75,9 @@ auto IterativeDeepeningAI::IterativeSearch(
// Initialize data structures for tracking scores at each depth
scoresByDepth.clear();
scoresByDepth.resize(commands->size());
scoresByDepth.resize(commands.size());
highestDepthCompleted.clear();
highestDepthCompleted.resize(commands->size(), 0);
highestDepthCompleted.resize(commands.size(), 0);
size_t currentDepth = 1;
size_t previousBestCommand = 0; // Track best command from previous depth
@@ -132,8 +132,7 @@ auto IterativeDeepeningAI::IterativeSearch(
evaluatedCount++;
// Check if this command is not END_TURN_COMMAND
if ((*commands)[cmdIndex]->GetCommandType() !=
net::eagle0::shardok::common::END_TURN_COMMAND) {
if (commands[cmdIndex].type() != net::eagle0::shardok::common::END_TURN_COMMAND) {
allEndTurnCommands = false;
}
}
@@ -144,7 +143,7 @@ auto IterativeDeepeningAI::IterativeSearch(
size_t currentBestCommand = 0;
ScoreValue currentBestScore = -std::numeric_limits<ScoreValue>::infinity();
for (size_t i = 0; i < commands->size(); ++i) {
for (size_t i = 0; i < commands.size(); ++i) {
if (highestDepthCompleted[i] >= currentDepth) {
if (scoresByDepth[i][currentDepth] > currentBestScore) {
currentBestScore = scoresByDepth[i][currentDepth];
@@ -157,20 +156,16 @@ auto IterativeDeepeningAI::IterativeSearch(
if (currentDepth > 1 && currentBestCommand != previousBestCommand) {
#if DEBUG_ITERATIVE_DEEPENING_TIMINGS
printf("ID AI: Best command changed at depth %lu:\n", currentDepth);
printf(" Depth %lu best: command %zu (score %.2f) - type: %s\n",
printf(" Depth %lu best: command %zu (score %.2f) - %s\n",
currentDepth - 1,
previousBestCommand,
scoresByDepth[previousBestCommand][currentDepth - 1],
net::eagle0::shardok::common::CommandType_Name(
(*commands)[previousBestCommand]->GetCommandType())
.c_str());
printf(" Depth %lu best: command %zu (score %.2f) - type: %s\n",
commands[previousBestCommand].DebugString().c_str());
printf(" Depth %lu best: command %zu (score %.2f) - %s\n",
currentDepth,
currentBestCommand,
currentBestScore,
net::eagle0::shardok::common::CommandType_Name(
(*commands)[currentBestCommand]->GetCommandType())
.c_str());
commands[currentBestCommand].DebugString().c_str());
#endif
}
@@ -249,7 +244,7 @@ auto IterativeDeepeningAI::IterativeSearch(
result.searchCompleted = result.minimumDepthCompleted;
result.timeUsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - startTime);
result.availableCommandCount = commands->size();
result.availableCommandCount = commands.size();
result.commandCountEvaluated = evaluatedCountAtHighestDepth;
result.completionReason = completionReason;
@@ -274,7 +269,7 @@ auto IterativeDeepeningAI::SearchCommandAtDepthWithEngine(
const ShardokEngine& guessedEngine,
const AIScoreCalculator& scorer,
const int maxRepeatCount,
const CommandListSPtr& commands,
const std::vector<CommandProto>& commands,
const size_t commandIndex,
const int desiredDepth,
const ScoreValue currentUtility,
@@ -284,10 +279,10 @@ auto IterativeDeepeningAI::SearchCommandAtDepthWithEngine(
result.depthAchieved = desiredDepth;
result.searchCompleted = true;
result.minimumDepthCompleted = true;
result.availableCommandCount = commands->size();
result.availableCommandCount = commands.size();
result.commandCountEvaluated = 1; // We're evaluating just this command
if (commandIndex >= commands->size()) {
if (commandIndex >= commands.size()) {
result.bestScore = 0.0;
std::promise<SearchResult> p;
p.set_value(result);
@@ -14,15 +14,16 @@
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCommand.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
namespace shardok {
// Forward declarations
class ShardokEngine;
using ScoreValue = double;
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
using BattalionTypeGetter = std::function<BattalionTypeSPtr(BattalionTypeId)>;
/// Reason why AI evaluation completed at the achieved depth.
@@ -69,7 +70,7 @@ public:
[[nodiscard]] SearchResult IterativeSearch(
const GameSettingsSPtr& settings,
const GameStateW& state,
const CommandListSPtr& commands,
const std::vector<CommandProto>& commands,
const AITimeBudget& initialBudget) const;
private:
@@ -92,7 +93,7 @@ private:
const ShardokEngine& guessedEngine,
const AIScoreCalculator& scorer,
int maxRepeatCount,
const CommandListSPtr& commands,
const std::vector<CommandProto>& commands,
size_t commandIndex,
int desiredDepth,
ScoreValue currentUtility,
@@ -10,15 +10,7 @@
#define DEBUG_FLEE_DECISIONS
// Enable to dump game state and debug tree to /tmp for debugging
// #define ENABLE_MCTS_DEBUG_DUMP
#ifdef ENABLE_MCTS_DEBUG_DUMP
#include <chrono>
#include <fstream>
#include <iomanip>
#include <sstream>
#endif
#include <google/protobuf/util/message_differencer.h>
#include "AIAttackerStrategySelector.hpp"
#include "AIConfig.hpp"
@@ -53,7 +45,6 @@ auto RoundsRemaining(const GameSettingsSPtr &settings, const GameStateView &gsv)
ShardokAIClient::ShardokAIClient(
const PlayerId playerId,
const bool isDefender,
const bool isAllAiBattle,
const HexMap *hexMap,
const SettingsGetter &settings,
const AIAlgorithmType aiAlgorithmType,
@@ -61,7 +52,6 @@ ShardokAIClient::ShardokAIClient(
const mcts::MCTSConfig &mctsConfig)
: playerId(playerId),
isDefender(isDefender),
isAllAiBattle(isAllAiBattle),
aiAlgorithmType(aiAlgorithmType),
scoringCalculatorType(scoringCalculatorType),
alCache(std::make_unique<AttackLocationsCache>(hexMap, settings)),
@@ -90,55 +80,33 @@ ShardokAIClient::ShardokAIClient(
apdCache->ConsolidateThreadLocalCache_Racy();
}
void CheckCommand(const CommandSPtr &realCommand, const CommandSPtr &guessedCommand) {
// Verify that the AI's guessed state produces the same available commands as the real state.
// We only compare fields that uniquely identify a command - metadata fields like action_points,
// will_unhide, next_round_target_info are not part of command identity.
void CheckCommand(const CommandProto &realDescriptor, const CommandProto &guessedDescriptor) {
string diff;
auto differencer = google::protobuf::util::MessageDifferencer();
differencer.IgnoreField(CommandProto::descriptor()->FindFieldByNumber(
CommandProto::kFollowUpCommandTypesFieldNumber));
differencer.ReportDifferencesToString(&diff);
if (!differencer.Compare(realDescriptor, guessedDescriptor)) {
printf("diff: %s\n\n", diff.c_str());
if (realCommand->GetCommandType() != guessedCommand->GetCommandType()) {
throw ShardokInternalErrorException("Command type mismatch between real and guessed state");
}
if (realCommand->GetPlayerId() != guessedCommand->GetPlayerId()) {
throw ShardokInternalErrorException("Player ID mismatch between real and guessed state");
}
if (realCommand->GetActorUnitId() != guessedCommand->GetActorUnitId()) {
throw ShardokInternalErrorException("Actor unit mismatch between real and guessed state");
}
if (realCommand->GetTargetRow() != guessedCommand->GetTargetRow() ||
realCommand->GetTargetColumn() != guessedCommand->GetTargetColumn()) {
throw ShardokInternalErrorException(
"Target coordinates mismatch between real and guessed state");
}
// For commands with odds (like FLEE), verify the odds match
if (realCommand->HasOdds() != guessedCommand->HasOdds()) {
throw ShardokInternalErrorException(
"Odds presence mismatch between real and guessed state");
}
if (realCommand->HasOdds() && guessedCommand->HasOdds()) {
if (realCommand->GetOddsPercentile() != guessedCommand->GetOddsPercentile()) {
throw ShardokInternalErrorException(
"Odds percentile mismatch between real and guessed state");
}
printf("Selected command descriptor\n%s\ndoes not match guessed\n%s\n\n",
realDescriptor.DebugString().c_str(),
guessedDescriptor.DebugString().c_str());
throw ShardokInternalErrorException("Illegal state for AI client");
}
}
auto ShardokAIClient::StandardChooseCommandIndex(
const GameSettingsSPtr &settings,
const GameStateW &guessedState,
const CommandListSPtr &realAvailableCommands) const -> CommandChoiceResults {
const vector<CommandProto> &realAvailableCommands) const -> CommandChoiceResults {
const auto settingsGetter = settings->GetGetter();
const auto guessedEngine = ShardokEngine(settings, guessedState);
const auto guessedCommands = guessedEngine.GetAvailableCommandsForAIPlayer(playerId);
const auto commandCount = guessedCommands->size();
const auto guessedCommands = guessedEngine.GetAvailableCommandProtos(playerId, false);
const auto commandCount = guessedCommands.size();
// Calculate time budget based on game situation using new dynamic per-command settings
const auto timeBudget =
CalculateTimeBudget(playerId, settings, guessedState, commandCount, isAllAiBattle);
const auto timeBudget = CalculateTimeBudget(playerId, settings, guessedState, commandCount);
// Configure MCTS based on proximity to enemy
// When far from enemy: use AVERAGING with maxPlayerFlips=0 (single-player lookahead)
@@ -148,14 +116,6 @@ auto ShardokAIClient::StandardChooseCommandIndex(
// - MINIMAX correctly models opponent choosing best response
// - Explores through one opponent turn for tactical accuracy
auto adjustedMCTSConfig = mctsConfig;
// For fair evaluation: simulate leaves to opponent's turn start (maxSimulationFlips=1)
// This ensures all leaves are scored at the same game phase:
// - Leaves at playerFlips=0 (still my turn): simulate through END_TURN to playerFlips=1
// - Leaves at playerFlips=1 (opponent's turn): evaluate immediately
// Result: consistent comparison of "what happens after I end my turn"
// adjustedMCTSConfig.maxSimulatfixionFlips = 1;
// adjustedMCTSConfig.maxPlayerFlips = 0;
// if (timeBudget.isCloseToEnemy) {
// adjustedMCTSConfig.maxPlayerFlips = 1;
@@ -171,10 +131,9 @@ auto ShardokAIClient::StandardChooseCommandIndex(
// }
// }
assert(commandCount == realAvailableCommands->size());
// Verify that the AI's guessed state produces the same available commands as reality
assert(commandCount == realAvailableCommands.size());
for (size_t i = 0; i < commandCount; i++) {
CheckCommand((*realAvailableCommands)[i], (*guessedCommands)[i]);
CheckCommand(realAvailableCommands[i], guessedCommands[i]);
}
// Extract values directly from settings for strategy selection
@@ -221,37 +180,6 @@ auto ShardokAIClient::StandardChooseCommandIndex(
IterativeDeepeningAI::SearchResult search_result;
if (aiAlgorithmType == AIAlgorithmType::MCTS) {
#ifdef ENABLE_MCTS_DEBUG_DUMP
// Set unique debug dump path for each action using timestamp
const auto now = std::chrono::system_clock::now();
const auto nowTime = std::chrono::system_clock::to_time_t(now);
const auto nowMs =
std::chrono::duration_cast<std::chrono::milliseconds>(now.time_since_epoch()) %
1000;
std::ostringstream pathStream;
pathStream << "/tmp/shardok_debug_"
<< std::put_time(std::localtime(&nowTime), "%Y%m%d_%H%M%S") << "_"
<< std::setfill('0') << std::setw(3) << nowMs.count() << "_p"
<< static_cast<int>(playerId) << ".txt";
adjustedMCTSConfig.debugDumpPath = pathStream.str();
// Also dump the game state to a file for reproduction
std::ostringstream statePathStream;
statePathStream << "/tmp/shardok_state_"
<< std::put_time(std::localtime(&nowTime), "%Y%m%d_%H%M%S") << "_"
<< std::setfill('0') << std::setw(3) << nowMs.count() << "_p"
<< static_cast<int>(playerId) << ".bin";
const std::string statePath = statePathStream.str();
// Write the flatbuffer game state to file using SaveTo method
if (guessedState.SaveTo(statePath)) {
printf("Game state dumped to: %s\n", statePath.c_str());
} else {
printf("Failed to dump game state to: %s\n", statePath.c_str());
}
#endif // ENABLE_MCTS_DEBUG_DUMP
// Using Monte Carlo Tree Search AI (with abstraction layer)
ShardokMCTSAI ai(
playerId,
@@ -291,8 +219,7 @@ auto ShardokAIClient::StandardChooseCommandIndex(
result.commandCountEvaluated,
result.availableCommandCount);
}
const auto chosenCommandType =
(*realAvailableCommands)[result.chosenIndex]->GetCommandType();
const auto chosenCommandType = realAvailableCommands[result.chosenIndex].type();
printf("ID AI: Search complete - achieved depth %d for best command %zu (%s)\n",
result.depthAchieved,
result.chosenIndex,
@@ -307,20 +234,19 @@ auto ShardokAIClient::StandardChooseCommandIndex(
auto ShardokAIClient::LateRoundAttackerChooseCommandIndex(
const GameSettingsSPtr &settings,
const GameStateW &guessedState,
const CommandListSPtr &realAvailableCommands) const -> CommandChoiceResults {
const vector<CommandProto> &realAvailableCommands) const -> CommandChoiceResults {
if (const auto dismissCommand = std::ranges::find_if(
*realAvailableCommands,
[](const CommandSPtr &cmd) {
return cmd->GetCommandType() ==
net::eagle0::shardok::common::DISMISS_UNIT_COMMAND;
realAvailableCommands,
[](const net::eagle0::shardok::api::CommandDescriptor &cmd) {
return cmd.type() == net::eagle0::shardok::common::DISMISS_UNIT_COMMAND;
});
dismissCommand == realAvailableCommands->end()) {
dismissCommand == realAvailableCommands.end()) {
return StandardChooseCommandIndex(settings, guessedState, realAvailableCommands);
} else {
CommandChoiceResults results{};
results.chosenIndex =
static_cast<size_t>(std::distance(realAvailableCommands->begin(), dismissCommand));
results.availableCommandCount = realAvailableCommands->size();
static_cast<size_t>(std::distance(realAvailableCommands.begin(), dismissCommand));
results.availableCommandCount = realAvailableCommands.size();
results.depthAchieved = 1; // Simple heuristic choice
results.commandCountEvaluated = 1; // Only evaluated one command type
results.completionReason =
@@ -332,13 +258,14 @@ auto ShardokAIClient::LateRoundAttackerChooseCommandIndex(
auto ShardokAIClient::FinalRoundAttackerChooseCommandIndex(
const GameSettingsSPtr &settings,
const GameStateW &guessedState,
const CommandListSPtr &realAvailableCommands) const -> CommandChoiceResults {
const auto fleeCommand =
std::ranges::find_if(*realAvailableCommands, [](const CommandSPtr &cmd) {
return cmd->GetCommandType() == net::eagle0::shardok::common::FLEE_COMMAND;
const vector<CommandProto> &realAvailableCommands) const -> CommandChoiceResults {
const auto fleeCommand = std::ranges::find_if(
realAvailableCommands,
[](const net::eagle0::shardok::api::CommandDescriptor &cmd) {
return cmd.type() == net::eagle0::shardok::common::FLEE_COMMAND;
});
if (fleeCommand == realAvailableCommands->end()) {
if (fleeCommand == realAvailableCommands.end()) {
return LateRoundAttackerChooseCommandIndex(settings, guessedState, realAvailableCommands);
}
@@ -367,7 +294,7 @@ auto ShardokAIClient::FinalRoundAttackerChooseCommandIndex(
if (fleeDecision.shouldFlee) {
CommandChoiceResults results{};
results.chosenIndex = fleeDecision.commandIndex;
results.availableCommandCount = realAvailableCommands->size();
results.availableCommandCount = realAvailableCommands.size();
results.depthAchieved = 1; // Heuristic choice
results.commandCountEvaluated = 1; // Only evaluated one command type
results.completionReason = EvaluationCompletionReason::RAN_OUT_OF_COMMANDS;
@@ -381,7 +308,7 @@ auto ShardokAIClient::FinalRoundAttackerChooseCommandIndex(
auto ShardokAIClient::ChooseCommandIndex(
const GameSettingsSPtr &settings,
const GameStateView &gsv,
const CommandListSPtr &realAvailableCommands) const -> CommandChoiceResults {
const vector<CommandProto> &realAvailableCommands) const -> CommandChoiceResults {
static int typeChosenCount[net::eagle0::shardok::common::CommandType_MAX + 1];
static int totalChoices = 0;
@@ -400,7 +327,7 @@ auto ShardokAIClient::ChooseCommandIndex(
results = StandardChooseCommandIndex(settings, guessedState, realAvailableCommands);
}
const auto chosenType = (*realAvailableCommands)[results.chosenIndex]->GetCommandType();
const auto chosenType = realAvailableCommands[results.chosenIndex].type();
typeChosenCount[static_cast<int>(chosenType)]++;
totalChoices++;
@@ -426,8 +353,8 @@ auto ShardokAIClient::ChooseCommandIndex(
auto ShardokAIClient::ChooseCommandIndex(const ShardokEngine &engine) const
-> CommandChoiceResults {
if (const auto &availableCommands = engine.GetAvailableCommandsForAIPlayer(playerId);
availableCommands->empty()) {
if (const auto &availableCommands = engine.GetAvailableCommandProtos(playerId, false);
availableCommands.empty()) {
printf("no commands for player %d\n", playerId);
throw ShardokInternalErrorException(
"Asked to choose a command, but there are none available");
@@ -18,7 +18,6 @@
#include "src/main/cpp/net/eagle0/shardok/ai/AIWaterCrossingCommandChooser.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/IterativeDeepeningAI.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCommand.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/game_state_view.pb.h"
namespace shardok {
@@ -41,7 +40,6 @@ class ShardokAIClient {
private:
const PlayerId playerId;
const bool isDefender;
const bool isAllAiBattle; // Whether this battle has only AI players (for faster time budgets)
const AIAlgorithmType aiAlgorithmType;
const ScoringCalculatorType scoringCalculatorType;
@@ -56,21 +54,25 @@ private:
[[nodiscard]] auto StandardChooseCommandIndex(
const GameSettingsSPtr& settings,
const GameStateW& guessedState,
const CommandListSPtr& realAvailableCommands) const -> CommandChoiceResults;
const vector<CommandProto>& realAvailableCommands) const -> CommandChoiceResults;
[[nodiscard]] auto LateRoundAttackerChooseCommandIndex(
const GameSettingsSPtr& settings,
const GameStateW& guessedState,
const CommandListSPtr& realAvailableCommands) const -> CommandChoiceResults;
const vector<CommandProto>& realAvailableCommands) const -> CommandChoiceResults;
[[nodiscard]] auto FinalRoundAttackerChooseCommandIndex(
const GameSettingsSPtr& settings,
const GameStateW& guessedState,
const CommandListSPtr& realAvailableCommands) const -> CommandChoiceResults;
const vector<CommandProto>& realAvailableCommands) const -> CommandChoiceResults;
[[nodiscard]] auto ChooseCommandIndex(
const GameSettingsSPtr& settings,
const net::eagle0::shardok::api::GameStateView& gsv,
const vector<CommandProto>& realAvailableCommands) const -> CommandChoiceResults;
public:
explicit ShardokAIClient(
PlayerId playerId,
bool isDefender,
bool isAllAiBattle,
const HexMap* hexMap,
const SettingsGetter& settings,
AIAlgorithmType aiAlgorithmType,
@@ -83,12 +85,6 @@ public:
[[nodiscard]] auto ChooseCommandIndex(const ShardokEngine& engine) const
-> CommandChoiceResults;
// Overload that works on copies of state - allows caller to release lock during AI thinking
[[nodiscard]] auto ChooseCommandIndex(
const GameSettingsSPtr& settings,
const net::eagle0::shardok::api::GameStateView& gsv,
const CommandListSPtr& realAvailableCommands) const -> CommandChoiceResults;
// MCTS configuration methods (only relevant when using MCTS algorithm)
[[nodiscard]] auto GetMCTSConfig() const -> const mcts::MCTSConfig& { return mctsConfig; }
void SetMCTSConfig(const mcts::MCTSConfig& config) { mctsConfig = config; }
@@ -4,7 +4,6 @@
#include "TranspositionTable.hpp"
#include <cinttypes>
#include <cstdio>
#include <cstring>
@@ -102,10 +101,10 @@ void TranspositionTable::clear() {
void TranspositionTable::printStats() const {
printf("TranspositionTable Stats:\n");
printf(" Probes: %" PRIu64 "\n", stats.probes.load());
printf(" Hits: %" PRIu64 " (%.1f%%)\n", stats.hits.load(), stats.hitRate());
printf(" Stores: %" PRIu64 "\n", stats.stores.load());
printf(" Collisions: %" PRIu64 "\n", stats.collisions.load());
printf(" Probes: %llu\n", stats.probes.load());
printf(" Hits: %llu (%.1f%%)\n", stats.hits.load(), stats.hitRate());
printf(" Stores: %llu\n", stats.stores.load());
printf(" Collisions: %llu\n", stats.collisions.load());
printf(" Table size: %zu entries (%.1f MB)\n",
TABLE_SIZE,
(TABLE_SIZE * sizeof(TTEntry)) / (1024.0 * 1024.0));
@@ -20,5 +20,6 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/library:shardok_c_types",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
],
)

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