mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 02:48:40 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77a071afcf |
@@ -1,4 +1,4 @@
|
||||
# bazel-1.0.0.bazelrc
|
||||
bazel-1.0.0.bazelrc
|
||||
|
||||
# for now: filter out annoying TASTY warnings
|
||||
common --ui_event_filters=-INFO
|
||||
@@ -29,10 +29,6 @@ common --host_cxxopt="--std=c++23"
|
||||
|
||||
common --javacopt="-Xlint:-options"
|
||||
|
||||
# std::filesystem and other modern C++ deps require macOS 10.15+.
|
||||
common:macos --macos_minimum_os=10.15
|
||||
common:macos --host_macos_minimum_os=10.15
|
||||
|
||||
# suppress warnings due to https://developer.apple.com/forums/thread/733317
|
||||
# Use host_linkopt for macOS-specific flags to avoid passing them to Linux cross-compilation
|
||||
common:macos --host_linkopt=-Wl,-no_warn_duplicate_libraries
|
||||
@@ -41,17 +37,11 @@ common:macos --host_linkopt=-Wl,-no_warn_duplicate_libraries
|
||||
# See: https://github.com/grpc/grpc/issues/37619
|
||||
common:macos --features=-module_maps
|
||||
|
||||
# Pin DEVELOPER_DIR so Apple repo rules use full Xcode instead of Command Line
|
||||
# Tools. The sync script writes .bazelrc.xcode with an Xcode build-version
|
||||
# marker so in-place Xcode updates refresh Bazel's generated Apple toolchains.
|
||||
# Pin DEVELOPER_DIR so the apple_cc_autoconf repo rule doesn't re-evaluate
|
||||
# when Xcode updates in-place. The sync script overrides this for mactools.
|
||||
common:macos --repo_env=DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||
|
||||
# Local machine or CI overrides. This lets Bazel-only runners use Command Line
|
||||
# Tools without installing full Xcode. This must stay before .bazelrc.xcode so
|
||||
# generated Xcode settings win when both files exist.
|
||||
try-import %workspace%/.bazelrc.local
|
||||
|
||||
# Xcode config for macOS builds. Generated by scripts/sync_bazel_xcode.sh.
|
||||
# Xcode config for mactools builds only. Generated by scripts/sync_bazel_xcode.sh.
|
||||
# Bakes the Xcode build version into action cache keys and sets DEVELOPER_DIR.
|
||||
try-import %workspace%/.bazelrc.xcode
|
||||
|
||||
|
||||
@@ -6,32 +6,28 @@ on:
|
||||
- cron: '0 */6 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
cleanup-expired:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Delete expired artifacts and artifacts older than 3 days
|
||||
- name: Delete artifacts older than 3 days
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
echo "Fetching all artifacts..."
|
||||
gh api "repos/${{ github.repository }}/actions/artifacts" \
|
||||
--paginate -q '.artifacts[] | "\(.id)\t\(.created_at)\t\(.expired)\t\(.name)"' > /tmp/all_artifacts.txt
|
||||
--paginate -q '.artifacts[] | "\(.id)\t\(.created_at)\t\(.name)"' > /tmp/all_artifacts.txt
|
||||
|
||||
total=$(wc -l < /tmp/all_artifacts.txt)
|
||||
echo "Found $total total artifacts"
|
||||
|
||||
cutoff=$(date -u -d '3 days ago' '+%Y-%m-%dT%H:%M:%SZ')
|
||||
echo "Deleting expired artifacts and artifacts created before $cutoff"
|
||||
echo "Deleting artifacts created before $cutoff"
|
||||
|
||||
deleted=0
|
||||
while IFS=$'\t' read -r id created_at expired name; do
|
||||
if [[ "$expired" == "true" || "$created_at" < "$cutoff" ]]; then
|
||||
while IFS=$'\t' read -r id created_at name; do
|
||||
if [[ "$created_at" < "$cutoff" ]]; then
|
||||
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$id" 2>/dev/null && deleted=$((deleted + 1))
|
||||
if [ $((deleted % 50)) -eq 0 ]; then
|
||||
echo "Deleted $deleted artifacts so far..."
|
||||
@@ -39,7 +35,7 @@ jobs:
|
||||
fi
|
||||
done < /tmp/all_artifacts.txt
|
||||
|
||||
echo "Cleanup complete. Deleted $deleted artifacts out of $total total."
|
||||
echo "Cleanup complete. Deleted $deleted expired artifacts out of $total total."
|
||||
rm -f /tmp/all_artifacts.txt
|
||||
|
||||
check-storage:
|
||||
@@ -51,12 +47,11 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# Calculate active artifact storage. The artifacts API can list expired
|
||||
# artifacts until they are explicitly deleted, so do not count them.
|
||||
# Calculate total artifact storage
|
||||
total_bytes=$(gh api "repos/${{ github.repository }}/actions/artifacts" \
|
||||
--paginate -q '.artifacts[] | select(.expired == false) | .size_in_bytes' | awk '{sum+=$1} END {print sum}')
|
||||
--paginate -q '.artifacts[].size_in_bytes' | awk '{sum+=$1} END {print sum}')
|
||||
|
||||
total_mb=$(( ${total_bytes:-0} / 1024 / 1024 ))
|
||||
total_mb=$((total_bytes / 1024 / 1024))
|
||||
echo "Total artifact storage: ${total_mb} MB"
|
||||
|
||||
# Fail if over 500MB
|
||||
@@ -66,7 +61,7 @@ jobs:
|
||||
echo "Largest artifacts:"
|
||||
# Save to temp file to avoid SIGPIPE/broken pipe errors with head
|
||||
gh api "repos/${{ github.repository }}/actions/artifacts" \
|
||||
--paginate -q '.artifacts[] | select(.expired == false) | "\(.size_in_bytes)\t\(.name)\t\(.created_at)"' > /tmp/artifacts.txt
|
||||
--paginate -q '.artifacts[] | "\(.size_in_bytes)\t\(.name)\t\(.created_at)"' > /tmp/artifacts.txt
|
||||
sort -rn /tmp/artifacts.txt | head -20 | \
|
||||
awk -F'\t' '{printf "%d MB\t%s\t%s\n", $1/1024/1024, $2, $3}'
|
||||
rm -f /tmp/artifacts.txt
|
||||
|
||||
@@ -11,7 +11,6 @@ on:
|
||||
- 'src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto'
|
||||
- 'src/main/resources/net/eagle0/attributions.json'
|
||||
- 'ci/BUILD.bazel'
|
||||
- 'ci/github_actions/ensure_bazel_installed.sh'
|
||||
- '.bazelrc'
|
||||
- '.github/workflows/auth_build.yml'
|
||||
workflow_dispatch:
|
||||
@@ -25,39 +24,17 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: auth-build-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build-auth:
|
||||
runs-on: [self-hosted, bazel]
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-auth.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Prepare non-LFS checkout
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig-no-lfs
|
||||
GIT_CONFIG_NOSYSTEM: "1"
|
||||
run: |
|
||||
git config --global --unset-all filter.lfs.process || true
|
||||
git config --global filter.lfs.smudge "cat"
|
||||
git config --global filter.lfs.clean "cat"
|
||||
git config --global filter.lfs.required false
|
||||
rm -f .git/hooks/post-checkout .git/hooks/post-merge .git/hooks/pre-push
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig-no-lfs
|
||||
GIT_CONFIG_NOSYSTEM: "1"
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
|
||||
- name: Ensure Bazel installed
|
||||
run: ./ci/github_actions/ensure_bazel_installed.sh
|
||||
|
||||
- name: Build Auth Server Docker image
|
||||
id: build-auth
|
||||
run: |
|
||||
@@ -151,8 +128,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Deploy auth service to production
|
||||
uses: appleboy/ssh-action@v1.2.5
|
||||
@@ -163,7 +138,7 @@ jobs:
|
||||
script_stop: true
|
||||
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,GH_OAUTH_CLIENT_ID,GH_OAUTH_CLIENT_SECRET,APPLE_SIGNIN_CLIENT_ID,APPLE_TEAM_ID,APPLE_SIGNIN_KEY_ID,APPLE_SIGNIN_PRIVATE_KEY,TWITCH_CLIENT_ID,TWITCH_CLIENT_SECRET,JWT_PRIVATE_KEY,FASTMAIL_API_TOKEN,FASTMAIL_FROM_EMAIL,FASTMAIL_FROM_NAME
|
||||
script: |
|
||||
set -e
|
||||
set -x
|
||||
cd /opt/eagle0
|
||||
|
||||
# Export env vars for docker compose (appleboy/ssh-action sets them but doesn't export)
|
||||
|
||||
@@ -16,7 +16,6 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
|
||||
- name: Show disk usage before cleanup
|
||||
|
||||
@@ -9,7 +9,6 @@ on:
|
||||
- 'MODULE.bazel'
|
||||
- 'BUILD.bazel'
|
||||
- '.bazelrc'
|
||||
- 'ci/github_actions/ensure_bazel_installed.sh'
|
||||
- '.github/workflows/bazel_test.yml'
|
||||
- '!src/main/csharp/**'
|
||||
- '!src/test/csharp/**'
|
||||
@@ -20,7 +19,6 @@ on:
|
||||
- 'MODULE.bazel'
|
||||
- 'BUILD.bazel'
|
||||
- '.bazelrc'
|
||||
- 'ci/github_actions/ensure_bazel_installed.sh'
|
||||
- '.github/workflows/bazel_test.yml'
|
||||
- '!src/main/csharp/**'
|
||||
- '!src/test/csharp/**'
|
||||
@@ -42,27 +40,9 @@ jobs:
|
||||
git config --local core.sparseCheckout false 2>/dev/null || true
|
||||
git config --local --unset extensions.worktreeConfig 2>/dev/null || true
|
||||
rm -f .git/info/sparse-checkout .git/config.worktree 2>/dev/null || true
|
||||
- name: Ensure Git LFS available for checkout
|
||||
run: |
|
||||
COMMON_PATHS=(/opt/homebrew/bin /usr/local/bin)
|
||||
for path in "${COMMON_PATHS[@]}"; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
export PATH="$path:$PATH"
|
||||
fi
|
||||
done
|
||||
|
||||
if command -v git-lfs >/dev/null 2>&1; then
|
||||
git-lfs version
|
||||
exit 0
|
||||
fi
|
||||
|
||||
brew install git-lfs
|
||||
git-lfs version
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
- name: Check BUILD.bazel dependencies
|
||||
run: ./scripts/check_build_deps.sh --strict
|
||||
@@ -82,30 +62,10 @@ jobs:
|
||||
git config --local core.sparseCheckout false 2>/dev/null || true
|
||||
git config --local --unset extensions.worktreeConfig 2>/dev/null || true
|
||||
rm -f .git/info/sparse-checkout .git/config.worktree 2>/dev/null || true
|
||||
- name: Ensure Git LFS available for checkout
|
||||
run: |
|
||||
COMMON_PATHS=(/opt/homebrew/bin /usr/local/bin)
|
||||
for path in "${COMMON_PATHS[@]}"; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
export PATH="$path:$PATH"
|
||||
fi
|
||||
done
|
||||
|
||||
if command -v git-lfs >/dev/null 2>&1; then
|
||||
git-lfs version
|
||||
exit 0
|
||||
fi
|
||||
|
||||
brew install git-lfs
|
||||
git-lfs version
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
- name: Ensure Bazel installed
|
||||
run: ./ci/github_actions/ensure_bazel_installed.sh
|
||||
- name: Run tests
|
||||
run: bazel test --build_event_json_file=test.json //src/test/... //src/main/go/...
|
||||
- name: Collect failed test logs
|
||||
|
||||
@@ -16,13 +16,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
clean: false
|
||||
|
||||
- name: Ensure Bazel installed
|
||||
run: ./ci/github_actions/ensure_bazel_installed.sh
|
||||
|
||||
- name: Clean up unreferenced blobs
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
|
||||
@@ -28,8 +28,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Build sysroot
|
||||
run: ./tools/sysroot/build_sysroot.sh
|
||||
@@ -84,8 +82,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up QEMU for ARM64 emulation
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
@@ -18,7 +18,6 @@ on:
|
||||
- '!src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto'
|
||||
- 'src/main/resources/**'
|
||||
- 'ci/BUILD.bazel'
|
||||
- 'ci/github_actions/ensure_bazel_installed.sh'
|
||||
- 'MODULE.bazel'
|
||||
- '.bazelrc'
|
||||
- 'docker-compose.prod.yml'
|
||||
@@ -69,38 +68,14 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Ensure Git LFS available for checkout
|
||||
if: steps.check-latest.outputs.skip != 'true'
|
||||
run: |
|
||||
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH="${COMMON_PATHS}:${PATH}"
|
||||
for path in /opt/homebrew/bin /usr/local/bin; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
fi
|
||||
done
|
||||
if ! command -v git-lfs >/dev/null 2>&1; then
|
||||
brew install git-lfs
|
||||
fi
|
||||
git-lfs --version
|
||||
|
||||
- name: Checkout repository
|
||||
if: steps.check-latest.outputs.skip != 'true'
|
||||
uses: actions/checkout@v6
|
||||
env:
|
||||
GIT_LFS_SKIP_SMUDGE: 1
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
|
||||
- name: Ensure Bazel installed
|
||||
if: steps.check-latest.outputs.skip != 'true'
|
||||
run: ./ci/github_actions/ensure_bazel_installed.sh
|
||||
|
||||
- name: Fetch LFS files needed for admin server
|
||||
if: steps.check-latest.outputs.skip != 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: ./ci/github_actions/fetch_lfs.sh --include="src/main/go/net/eagle0/admin_server/static/tiles/*"
|
||||
|
||||
- name: Build all Docker images
|
||||
@@ -264,37 +239,14 @@ jobs:
|
||||
FASTMAIL_API_TOKEN: ${{ secrets.FASTMAIL_API_TOKEN }}
|
||||
FASTMAIL_FROM_EMAIL: ${{ secrets.FASTMAIL_FROM_EMAIL }}
|
||||
FASTMAIL_FROM_NAME: ${{ secrets.FASTMAIL_FROM_NAME }}
|
||||
EAGLE_HISTORY_BACKEND: ${{ secrets.EAGLE_HISTORY_BACKEND }}
|
||||
EAGLE_POSTGRES_HOST: ${{ secrets.EAGLE_POSTGRES_HOST }}
|
||||
EAGLE_POSTGRES_PORT: ${{ secrets.EAGLE_POSTGRES_PORT }}
|
||||
EAGLE_POSTGRES_DATABASE: ${{ secrets.EAGLE_POSTGRES_DATABASE }}
|
||||
EAGLE_POSTGRES_USER: ${{ secrets.EAGLE_POSTGRES_USER }}
|
||||
EAGLE_POSTGRES_PASSWORD: ${{ secrets.EAGLE_POSTGRES_PASSWORD }}
|
||||
DO_DROPLET_IP: ${{ secrets.DO_DROPLET_IP }}
|
||||
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
NOTIFY_SECRET: ${{ secrets.EAGLE_NOTIFY_SECRET }}
|
||||
GITHUB_TOKEN_FOR_ADMIN: ${{ secrets.ADMIN_GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Ensure Git LFS available for checkout
|
||||
run: |
|
||||
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH="${COMMON_PATHS}:${PATH}"
|
||||
for path in /opt/homebrew/bin /usr/local/bin; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
fi
|
||||
done
|
||||
if ! command -v git-lfs >/dev/null 2>&1; then
|
||||
brew install git-lfs
|
||||
fi
|
||||
git-lfs --version
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
env:
|
||||
GIT_LFS_SKIP_SMUDGE: 1
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
|
||||
- name: Setup SSH key
|
||||
@@ -305,25 +257,11 @@ jobs:
|
||||
ssh-keyscan -H "$DO_DROPLET_IP" >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
- name: Download warmup binary
|
||||
id: download-warmup
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: warmup-binary
|
||||
path: scripts/bin/
|
||||
|
||||
- name: Build warmup binary fallback
|
||||
if: steps.download-warmup.outcome != 'success'
|
||||
run: |
|
||||
echo "::warning::warmup-binary artifact was unavailable; rebuilding warmup binary before deploy"
|
||||
./ci/github_actions/ensure_bazel_installed.sh
|
||||
bazel build \
|
||||
--platforms=//:linux_x86_64 \
|
||||
--extra_toolchains=@llvm_toolchain_linux//:all \
|
||||
//ci:warmup_tar
|
||||
mkdir -p scripts/bin
|
||||
tar -xf bazel-bin/ci/warmup_tar.tar -C scripts/bin --strip-components=1
|
||||
|
||||
- name: Copy config files to droplet
|
||||
run: |
|
||||
# Create directory structure on remote
|
||||
@@ -342,7 +280,7 @@ jobs:
|
||||
- name: Deploy to production droplet
|
||||
run: |
|
||||
ssh -i ~/.ssh/deploy_key deploy@"$DO_DROPLET_IP" bash -s << DEPLOY_SCRIPT
|
||||
set -e
|
||||
set -ex
|
||||
cd /opt/eagle0
|
||||
|
||||
# =================================================================
|
||||
@@ -380,10 +318,6 @@ jobs:
|
||||
validate_env "EAGLE_IMAGE" "${EAGLE_IMAGE}" "" || VALIDATION_FAILED=1
|
||||
validate_env "JWT_PRIVATE_KEY" "${JWT_PRIVATE_KEY}" "" || VALIDATION_FAILED=1
|
||||
validate_env "DO_REGISTRY_TOKEN" "${DO_REGISTRY_TOKEN}" "" || VALIDATION_FAILED=1
|
||||
validate_env "EAGLE_POSTGRES_HOST" "${EAGLE_POSTGRES_HOST}" "" || VALIDATION_FAILED=1
|
||||
validate_env "EAGLE_POSTGRES_DATABASE" "${EAGLE_POSTGRES_DATABASE}" "" || VALIDATION_FAILED=1
|
||||
validate_env "EAGLE_POSTGRES_USER" "${EAGLE_POSTGRES_USER}" "" || VALIDATION_FAILED=1
|
||||
validate_env "EAGLE_POSTGRES_PASSWORD" "${EAGLE_POSTGRES_PASSWORD}" "" || VALIDATION_FAILED=1
|
||||
|
||||
if [ "\${VALIDATION_FAILED}" -eq 1 ]; then
|
||||
echo ""
|
||||
@@ -431,12 +365,6 @@ jobs:
|
||||
export FASTMAIL_API_TOKEN="${FASTMAIL_API_TOKEN}"
|
||||
export FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
|
||||
export FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
|
||||
export EAGLE_HISTORY_BACKEND="${EAGLE_HISTORY_BACKEND:-postgres}"
|
||||
export EAGLE_POSTGRES_HOST="${EAGLE_POSTGRES_HOST}"
|
||||
export EAGLE_POSTGRES_PORT="${EAGLE_POSTGRES_PORT}"
|
||||
export EAGLE_POSTGRES_DATABASE="${EAGLE_POSTGRES_DATABASE}"
|
||||
export EAGLE_POSTGRES_USER="${EAGLE_POSTGRES_USER}"
|
||||
export EAGLE_POSTGRES_PASSWORD="${EAGLE_POSTGRES_PASSWORD}"
|
||||
export DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
|
||||
export NOTIFY_SECRET="${NOTIFY_SECRET}"
|
||||
export GITHUB_TOKEN="${GITHUB_TOKEN_FOR_ADMIN}"
|
||||
|
||||
@@ -11,7 +11,6 @@ on:
|
||||
- 'MODULE.bazel'
|
||||
- 'BUILD.bazel'
|
||||
- '.bazelrc'
|
||||
- 'ci/github_actions/ensure_bazel_installed.sh'
|
||||
- '.github/workflows/eagle_build.yml'
|
||||
|
||||
concurrency:
|
||||
@@ -26,29 +25,9 @@ jobs:
|
||||
runs-on: [self-hosted, bazel]
|
||||
|
||||
steps:
|
||||
- name: Ensure Git LFS available for checkout
|
||||
run: |
|
||||
COMMON_PATHS=(/opt/homebrew/bin /usr/local/bin)
|
||||
for path in "${COMMON_PATHS[@]}"; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
export PATH="$path:$PATH"
|
||||
fi
|
||||
done
|
||||
|
||||
if command -v git-lfs >/dev/null 2>&1; then
|
||||
git-lfs version
|
||||
exit 0
|
||||
fi
|
||||
|
||||
brew install git-lfs
|
||||
git-lfs version
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
- name: Ensure Bazel installed
|
||||
run: ./ci/github_actions/ensure_bazel_installed.sh
|
||||
- name: Build Eagle server
|
||||
run: bazel build //src/main/scala/net/eagle0/eagle:eagle_server
|
||||
|
||||
@@ -5,12 +5,10 @@ on:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- ".github/workflows/installer_build.yml"
|
||||
- "ci/github_actions/ensure_bazel_installed.sh"
|
||||
- "src/main/go/net/eagle0/clients/win/installer/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/installer_build.yml"
|
||||
- "ci/github_actions/ensure_bazel_installed.sh"
|
||||
- "src/main/go/net/eagle0/clients/win/installer/**"
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -23,32 +21,11 @@ jobs:
|
||||
runs-on: [self-hosted, bazel]
|
||||
|
||||
steps:
|
||||
- name: Ensure Git LFS available for checkout
|
||||
run: |
|
||||
COMMON_PATHS=(/opt/homebrew/bin /usr/local/bin)
|
||||
for path in "${COMMON_PATHS[@]}"; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
export PATH="$path:$PATH"
|
||||
fi
|
||||
done
|
||||
|
||||
if command -v git-lfs >/dev/null 2>&1; then
|
||||
git-lfs version
|
||||
exit 0
|
||||
fi
|
||||
|
||||
brew install git-lfs
|
||||
git-lfs version
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
clean: false
|
||||
|
||||
- name: Ensure Bazel installed
|
||||
run: ./ci/github_actions/ensure_bazel_installed.sh
|
||||
|
||||
- name: Build Go installer for Windows
|
||||
env:
|
||||
MANIFEST_PUBLIC_KEY: ${{ secrets.MANIFEST_PUBLIC_KEY }}
|
||||
|
||||
@@ -13,7 +13,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
actions: read
|
||||
|
||||
env:
|
||||
# Runner-specific build directory to allow parallel builds on multiple runners
|
||||
@@ -122,26 +122,8 @@ jobs:
|
||||
git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
|
||||
xargs -r git update-ref -d 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Ensure Git LFS available for checkout
|
||||
run: |
|
||||
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH="${COMMON_PATHS}:${PATH}"
|
||||
for path in /opt/homebrew/bin /usr/local/bin; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
fi
|
||||
done
|
||||
if ! command -v git-lfs >/dev/null 2>&1; then
|
||||
brew install git-lfs
|
||||
fi
|
||||
git-lfs --version
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
env:
|
||||
GIT_LFS_SKIP_SMUDGE: 1
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false # Fetch LFS after checkout to avoid stale ref issues
|
||||
clean: false # Library/ persists between runs on self-hosted runners
|
||||
fetch-depth: 0
|
||||
@@ -152,8 +134,6 @@ jobs:
|
||||
rm -rf src/main/csharp/net/eagle0/clients/unity/eagle0/Library/Bee/
|
||||
|
||||
- name: Fetch LFS files
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: ./ci/github_actions/fetch_lfs.sh
|
||||
|
||||
- name: Ensure Unity version installed
|
||||
@@ -190,75 +170,11 @@ jobs:
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_ios.log
|
||||
retention-days: 3
|
||||
|
||||
- name: Package generated iOS project
|
||||
if: success()
|
||||
run: |
|
||||
tar -czf "$RUNNER_TEMP/eagle0-ios-project.tar.gz" -C "$EAGLE0_BUILD_DIR" eagle0iOS
|
||||
|
||||
- name: Upload generated iOS project
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: eagle0-ios-project-${{ github.run_id }}
|
||||
path: ${{ runner.temp }}/eagle0-ios-project.tar.gz
|
||||
retention-days: 1
|
||||
|
||||
- name: Cleanup build directory
|
||||
if: always()
|
||||
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
|
||||
|
||||
archive-and-upload:
|
||||
needs: build-unity
|
||||
runs-on: [self-hosted, macOS, testflight]
|
||||
|
||||
steps:
|
||||
- name: Ensure Git LFS available for checkout
|
||||
run: |
|
||||
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH="${COMMON_PATHS}:${PATH}"
|
||||
for path in /opt/homebrew/bin /usr/local/bin; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
fi
|
||||
done
|
||||
if ! command -v git-lfs >/dev/null 2>&1; then
|
||||
brew install git-lfs
|
||||
fi
|
||||
git-lfs --version
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
env:
|
||||
GIT_LFS_SKIP_SMUDGE: 1
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
|
||||
- name: Clean build directory
|
||||
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
|
||||
|
||||
- name: Download generated iOS project
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: eagle0-ios-project-${{ github.run_id }}
|
||||
path: ${{ runner.temp }}
|
||||
|
||||
- name: Extract generated iOS project
|
||||
run: |
|
||||
mkdir -p "$EAGLE0_BUILD_DIR"
|
||||
tar -xzf "$RUNNER_TEMP/eagle0-ios-project.tar.gz" -C "$EAGLE0_BUILD_DIR"
|
||||
|
||||
- name: Delete generated iOS project artifact
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
artifact_id=$(gh api "repos/${{ github.repository }}/actions/artifacts" \
|
||||
--paginate \
|
||||
--jq '.artifacts[] | select(.name == "eagle0-ios-project-${{ github.run_id }}") | .id')
|
||||
|
||||
if [ -n "$artifact_id" ]; then
|
||||
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id"
|
||||
fi
|
||||
|
||||
- name: Install Signing Certificate
|
||||
env:
|
||||
IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }}
|
||||
|
||||
@@ -20,7 +20,6 @@ on:
|
||||
- "scripts/notarize_wait.sh"
|
||||
- "ci/github_actions/build_mac.sh"
|
||||
- "ci/github_actions/build_unity_mac.sh"
|
||||
- "ci/github_actions/ensure_bazel_installed.sh"
|
||||
- "ci/github_actions/upload_addressables.sh"
|
||||
- "ci/github_actions/ensure_unity_installed.sh"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
|
||||
@@ -40,7 +39,6 @@ on:
|
||||
- "scripts/notarize_wait.sh"
|
||||
- "ci/github_actions/build_mac.sh"
|
||||
- "ci/github_actions/build_unity_mac.sh"
|
||||
- "ci/github_actions/ensure_bazel_installed.sh"
|
||||
- "ci/mac/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -83,26 +81,8 @@ jobs:
|
||||
git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
|
||||
xargs -r git update-ref -d 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Ensure Git LFS available for checkout
|
||||
run: |
|
||||
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH="${COMMON_PATHS}:${PATH}"
|
||||
for path in /opt/homebrew/bin /usr/local/bin; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
fi
|
||||
done
|
||||
if ! command -v git-lfs >/dev/null 2>&1; then
|
||||
brew install git-lfs
|
||||
fi
|
||||
git-lfs --version
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
env:
|
||||
GIT_LFS_SKIP_SMUDGE: 1
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false # Fetch LFS after checkout to avoid stale ref issues
|
||||
clean: false # Library/ persists between runs on self-hosted runners
|
||||
fetch-depth: 0 # For version numbering from git history
|
||||
@@ -129,16 +109,11 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Fetch LFS files
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: ./ci/github_actions/fetch_lfs.sh
|
||||
|
||||
- name: Ensure Unity version installed
|
||||
run: ./ci/github_actions/ensure_unity_installed.sh mac
|
||||
|
||||
- name: Ensure Bazel installed
|
||||
run: ./ci/github_actions/ensure_bazel_installed.sh
|
||||
|
||||
- name: Sync Bazel Xcode config
|
||||
run: ./scripts/sync_bazel_xcode.sh
|
||||
|
||||
@@ -147,9 +122,7 @@ jobs:
|
||||
|
||||
- name: Save build SHA for Bee/ cache invalidation
|
||||
if: success()
|
||||
run: |
|
||||
mkdir -p src/main/csharp/net/eagle0/clients/unity/eagle0/Library
|
||||
git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
|
||||
run: git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
|
||||
|
||||
- name: Upload Addressables to CDN
|
||||
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
||||
@@ -277,25 +250,8 @@ jobs:
|
||||
runs-on: [self-hosted, macOS, notarize]
|
||||
|
||||
steps:
|
||||
- name: Ensure Git LFS available for checkout
|
||||
run: |
|
||||
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH="${COMMON_PATHS}:${PATH}"
|
||||
for path in /opt/homebrew/bin /usr/local/bin; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
fi
|
||||
done
|
||||
if ! command -v git-lfs >/dev/null 2>&1; then
|
||||
brew install git-lfs
|
||||
fi
|
||||
git-lfs --version
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
env:
|
||||
GIT_LFS_SKIP_SMUDGE: 1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: scripts
|
||||
|
||||
- name: Clean download directory
|
||||
@@ -342,30 +298,13 @@ jobs:
|
||||
deploy:
|
||||
needs: [build-and-sign, wait-notarization]
|
||||
if: needs.build-and-sign.outputs.should_deploy == 'true'
|
||||
runs-on: [self-hosted, macOS, notarize]
|
||||
runs-on: [self-hosted, macOS, unity-mac]
|
||||
outputs:
|
||||
deployed_version: ${{ steps.deploy-mac.outputs.deployed_version }}
|
||||
|
||||
steps:
|
||||
- name: Ensure Git LFS available for checkout
|
||||
run: |
|
||||
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH="${COMMON_PATHS}:${PATH}"
|
||||
for path in /opt/homebrew/bin /usr/local/bin; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
fi
|
||||
done
|
||||
if ! command -v git-lfs >/dev/null 2>&1; then
|
||||
brew install git-lfs
|
||||
fi
|
||||
git-lfs --version
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
env:
|
||||
GIT_LFS_SKIP_SMUDGE: 1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0 # For version numbering
|
||||
|
||||
- name: Clean download directory
|
||||
@@ -383,9 +322,6 @@ jobs:
|
||||
ditto -x -k eagle0.app.zip .
|
||||
rm eagle0.app.zip
|
||||
|
||||
- name: Ensure Bazel installed
|
||||
run: ./ci/github_actions/ensure_bazel_installed.sh
|
||||
|
||||
- name: Sync Bazel Xcode config
|
||||
run: ./scripts/sync_bazel_xcode.sh
|
||||
|
||||
@@ -401,13 +337,8 @@ jobs:
|
||||
echo "$SPARKLE_EDDSA_PRIVATE_KEY" > "$SPARKLE_PRIVATE_KEY_PATH"
|
||||
chmod 600 "$SPARKLE_PRIVATE_KEY_PATH"
|
||||
|
||||
# Install dmgbuild in an isolated venv. Homebrew Python rejects system
|
||||
# installs under PEP 668, and mac_build_handler shells out to dmgbuild.
|
||||
DMGBUILD_VENV="${{ env.EAGLE0_BUILD_DIR }}/dmgbuild-venv"
|
||||
python3 -m venv "$DMGBUILD_VENV"
|
||||
"$DMGBUILD_VENV/bin/python" -m pip install --upgrade pip
|
||||
"$DMGBUILD_VENV/bin/python" -m pip install dmgbuild
|
||||
export PATH="$DMGBUILD_VENV/bin:$PATH"
|
||||
# Install dmgbuild (creates .DS_Store programmatically, no AppleScript needed)
|
||||
pip3 install dmgbuild
|
||||
|
||||
# Background image for styled DMG
|
||||
BACKGROUND_PATH="$(pwd)/ci/mac/dmg/background.png"
|
||||
|
||||
@@ -39,9 +39,9 @@ jobs:
|
||||
echo "Dry run: ${DRY_RUN}"
|
||||
echo ""
|
||||
|
||||
# List repositories via JSON. Text output can wrap table rows and make
|
||||
# headers/tags/digests look like repository names.
|
||||
REPOS=$(doctl registry repository list-v2 "${REGISTRY}" --output json | jq -r '.[] | .name // .Name // empty')
|
||||
# List of repositories to clean
|
||||
# Use tail to skip header row in case --no-header doesn't work
|
||||
REPOS=$(doctl registry repository list-v2 --format Name --no-header | grep -v '^Name$' | grep -v '^$')
|
||||
|
||||
for REPO in $REPOS; do
|
||||
echo "=== Processing repository: ${REPO} ==="
|
||||
@@ -88,20 +88,5 @@ jobs:
|
||||
if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'false')
|
||||
run: |
|
||||
echo "Starting garbage collection..."
|
||||
set +e
|
||||
GC_OUTPUT=$(doctl registry garbage-collection start --force 2>&1)
|
||||
GC_STATUS=$?
|
||||
set -e
|
||||
|
||||
echo "$GC_OUTPUT"
|
||||
if [ "$GC_STATUS" -eq 0 ]; then
|
||||
echo "Garbage collection started. It may take a few minutes to complete."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if echo "$GC_OUTPUT" | grep -q "automated garbage collection is enabled"; then
|
||||
echo "Automated garbage collection is enabled for this registry; skipping manual garbage collection."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit "$GC_STATUS"
|
||||
doctl registry garbage-collection start --force
|
||||
echo "Garbage collection started. It may take a few minutes to complete."
|
||||
|
||||
@@ -9,7 +9,6 @@ on:
|
||||
- 'src/main/protobuf/net/eagle0/common/**'
|
||||
- 'src/main/resources/net/eagle0/shardok/**'
|
||||
- 'ci/BUILD.bazel'
|
||||
- 'ci/github_actions/ensure_bazel_installed.sh'
|
||||
- 'MODULE.bazel'
|
||||
- '.bazelrc'
|
||||
- '.github/workflows/shardok_arm64_build.yml'
|
||||
@@ -34,29 +33,11 @@ jobs:
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Prepare non-LFS checkout
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig-no-lfs
|
||||
GIT_CONFIG_NOSYSTEM: "1"
|
||||
run: |
|
||||
git config --global --unset-all filter.lfs.process || true
|
||||
git config --global filter.lfs.smudge "cat"
|
||||
git config --global filter.lfs.clean "cat"
|
||||
git config --global filter.lfs.required false
|
||||
rm -f .git/hooks/post-checkout .git/hooks/post-merge .git/hooks/pre-push
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
env:
|
||||
GIT_CONFIG_GLOBAL: ${{ runner.temp }}/gitconfig-no-lfs
|
||||
GIT_CONFIG_NOSYSTEM: "1"
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
|
||||
- name: Ensure Bazel installed
|
||||
run: ./ci/github_actions/ensure_bazel_installed.sh
|
||||
|
||||
- name: Build Shardok ARM64 binary (cross-compile for Linux ARM64)
|
||||
run: |
|
||||
set -ex
|
||||
@@ -199,55 +180,33 @@ jobs:
|
||||
# Add host key to known_hosts to avoid prompt
|
||||
ssh-keyscan -H ${{ secrets.HETZNER_IP }} >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
- name: Pull Shardok image tarball for Hetzner
|
||||
env:
|
||||
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
# Hetzner is IPv6-only, while Docker can be redirected to IPv4-only
|
||||
# DigitalOcean registry/blob endpoints. Pull on the runner and copy
|
||||
# the Docker-loadable tarball over SSH instead.
|
||||
CRANE_VERSION="v0.20.2"
|
||||
CRANE_DIR="${HOME}/.local/bin"
|
||||
CRANE="${CRANE_DIR}/crane"
|
||||
if [ ! -x "$CRANE" ] || ! "$CRANE" version 2>/dev/null | grep -q "0.20.2"; then
|
||||
echo "Installing crane ${CRANE_VERSION}..."
|
||||
mkdir -p "$CRANE_DIR"
|
||||
curl -sL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Darwin_arm64.tar.gz" | tar xzf - -C "$CRANE_DIR" crane
|
||||
chmod +x "$CRANE"
|
||||
else
|
||||
echo "Using cached crane at $CRANE"
|
||||
fi
|
||||
|
||||
AUTH=$(echo -n "${DO_REGISTRY_TOKEN}:${DO_REGISTRY_TOKEN}" | base64)
|
||||
mkdir -p ~/.docker
|
||||
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
|
||||
IMAGE_TAR="${RUNNER_TEMP}/shardok-arm64-image.tar"
|
||||
"$CRANE" pull "${SHARDOK_IMAGE}" "$IMAGE_TAR"
|
||||
echo "SHARDOK_IMAGE_TAR=$IMAGE_TAR" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Copy Shardok image tarball to Hetzner
|
||||
run: |
|
||||
set -ex
|
||||
ssh -i ~/.ssh/hetzner_deploy -o StrictHostKeyChecking=accept-new deploy@${{ secrets.HETZNER_IP }} \
|
||||
"cat > /tmp/shardok-arm64-image.tar" < "$SHARDOK_IMAGE_TAR"
|
||||
|
||||
- name: Deploy to Hetzner
|
||||
run: |
|
||||
ssh -i ~/.ssh/hetzner_deploy -o StrictHostKeyChecking=accept-new deploy@${{ secrets.HETZNER_IP }} << 'ENDSSH'
|
||||
set -ex
|
||||
cd /opt/eagle0
|
||||
|
||||
SHARDOK_IMAGE="${{ needs.build-shardok-arm64.outputs.image_tag }}"
|
||||
echo "Loading Shardok ARM64 image: $SHARDOK_IMAGE"
|
||||
docker load -i /tmp/shardok-arm64-image.tar
|
||||
rm -f /tmp/shardok-arm64-image.tar
|
||||
docker image inspect "$SHARDOK_IMAGE" > /dev/null
|
||||
echo "Loaded image ID: $(docker image inspect --format '{{.Id}}' "$SHARDOK_IMAGE")"
|
||||
echo "Shardok binary SHA256:"
|
||||
docker run --rm --entrypoint sha256sum "$SHARDOK_IMAGE" /app/shardok-server
|
||||
# Login to registry
|
||||
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
|
||||
|
||||
echo "Deploying Shardok ARM64: ${{ needs.build-shardok-arm64.outputs.image_tag }}"
|
||||
|
||||
# Pre-warm NAT64 path to DigitalOcean registry (IPv6-only Hetzner → IPv4 registry)
|
||||
curl -sf --max-time 10 https://registry.digitalocean.com/v2/ > /dev/null 2>&1 || true
|
||||
|
||||
# Pull the new image (retry up to 3 times for NAT64 connectivity flakiness)
|
||||
for attempt in 1 2 3; do
|
||||
echo "Pull attempt $attempt..."
|
||||
if docker pull "${{ needs.build-shardok-arm64.outputs.image_tag }}"; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "ERROR: docker pull failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
echo "Pull failed, retrying in 10s..."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
# Stop and remove any container using port 40042 or named shardok*
|
||||
docker ps -q --filter "publish=40042" | xargs -r docker stop
|
||||
@@ -265,12 +224,11 @@ jobs:
|
||||
-v /usr/local/share/eagle0:/usr/local/share/eagle0:ro \
|
||||
-e SHARDOK_RESOURCES_PATH=/app/resources \
|
||||
-e SHARDOK_MAPS_PATH=/app/resources/maps \
|
||||
"$SHARDOK_IMAGE"
|
||||
"${{ needs.build-shardok-arm64.outputs.image_tag }}"
|
||||
|
||||
# Wait and verify
|
||||
sleep 5
|
||||
docker ps | grep shardok-ai
|
||||
echo "Running container image ID: $(docker inspect --format '{{.Image}}' shardok-ai)"
|
||||
|
||||
# Cleanup old images
|
||||
docker image prune -f
|
||||
|
||||
@@ -5,14 +5,13 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/main/cpp/**'
|
||||
- 'src/main/protobuf/net/eagle0/shardok/**'
|
||||
- 'src/main/protobuf/net/eagle0/common/**'
|
||||
- 'src/main/proto/net/eagle0/shardok/**'
|
||||
- 'src/main/proto/net/eagle0/common/**'
|
||||
- 'src/main/go/net/eagle0/build/**'
|
||||
- 'WORKSPACE'
|
||||
- 'MODULE.bazel'
|
||||
- 'BUILD.bazel'
|
||||
- '.bazelrc'
|
||||
- 'ci/github_actions/ensure_bazel_installed.sh'
|
||||
- '.github/workflows/shardok_build.yml'
|
||||
|
||||
concurrency:
|
||||
@@ -27,29 +26,9 @@ jobs:
|
||||
runs-on: [self-hosted, bazel]
|
||||
|
||||
steps:
|
||||
- name: Ensure Git LFS available for checkout
|
||||
run: |
|
||||
COMMON_PATHS=(/opt/homebrew/bin /usr/local/bin)
|
||||
for path in "${COMMON_PATHS[@]}"; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
export PATH="$path:$PATH"
|
||||
fi
|
||||
done
|
||||
|
||||
if command -v git-lfs >/dev/null 2>&1; then
|
||||
git-lfs version
|
||||
exit 0
|
||||
fi
|
||||
|
||||
brew install git-lfs
|
||||
git-lfs version
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
- name: Ensure Bazel installed
|
||||
run: ./ci/github_actions/ensure_bazel_installed.sh
|
||||
- name: Build Shardok server
|
||||
run: bazel build -c opt //src/main/cpp/net/eagle0/shardok:shardok-server
|
||||
|
||||
@@ -15,8 +15,6 @@ on:
|
||||
- "scripts/build_plugins.sh"
|
||||
- "scripts/build_windows_plugin.sh"
|
||||
- "ci/github_actions/build_unity.sh"
|
||||
- "ci/github_actions/test_unity_editmode.sh"
|
||||
- "ci/github_actions/ensure_bazel_installed.sh"
|
||||
- "ci/github_actions/upload_addressables.sh"
|
||||
- "ci/github_actions/ensure_unity_installed.sh"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
|
||||
@@ -36,8 +34,6 @@ on:
|
||||
- "scripts/build_plugins.sh"
|
||||
- "scripts/build_windows_plugin.sh"
|
||||
- "ci/github_actions/build_unity.sh"
|
||||
- "ci/github_actions/test_unity_editmode.sh"
|
||||
- "ci/github_actions/ensure_bazel_installed.sh"
|
||||
- "ci/github_actions/upload_addressables.sh"
|
||||
- "ci/github_actions/ensure_unity_installed.sh"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
|
||||
@@ -73,26 +69,8 @@ jobs:
|
||||
git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
|
||||
xargs -r git update-ref -d 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Ensure Git LFS available for checkout
|
||||
run: |
|
||||
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH="${COMMON_PATHS}:${PATH}"
|
||||
for path in /opt/homebrew/bin /usr/local/bin; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
fi
|
||||
done
|
||||
if ! command -v git-lfs >/dev/null 2>&1; then
|
||||
brew install git-lfs
|
||||
fi
|
||||
git-lfs --version
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
env:
|
||||
GIT_LFS_SKIP_SMUDGE: 1
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false # Fetch LFS after checkout to avoid stale ref issues
|
||||
clean: false # Library/ persists between runs on self-hosted runners
|
||||
|
||||
@@ -132,28 +110,19 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Fetch LFS files
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: ./ci/github_actions/fetch_lfs.sh
|
||||
- name: Ensure Unity version installed
|
||||
run: ./ci/github_actions/ensure_unity_installed.sh windows
|
||||
- name: Ensure Bazel installed
|
||||
run: ./ci/github_actions/ensure_bazel_installed.sh
|
||||
- name: Run Unity EditMode tests
|
||||
run: ./ci/github_actions/test_unity_editmode.sh
|
||||
- name: Build Windows unity
|
||||
run: ./ci/github_actions/build_unity.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0WIN"
|
||||
|
||||
- name: Save build SHA for Bee/ cache invalidation
|
||||
if: success()
|
||||
run: |
|
||||
mkdir -p src/main/csharp/net/eagle0/clients/unity/eagle0/Library
|
||||
git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
|
||||
run: git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
|
||||
|
||||
- name: Save Unity version for Library/ cache invalidation
|
||||
if: success()
|
||||
run: |
|
||||
mkdir -p src/main/csharp/net/eagle0/clients/unity/eagle0/Library
|
||||
grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //' > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_unity_version
|
||||
|
||||
- name: Upload Addressables to CDN
|
||||
@@ -207,15 +176,6 @@ jobs:
|
||||
name: editor_win.log
|
||||
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_win.log
|
||||
retention-days: 3
|
||||
- name: Archive EditMode test artifacts
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: unity_editmode_tests
|
||||
path: |
|
||||
${{ env.EAGLE0_BUILD_DIR }}/editor_editmode_tests.log
|
||||
${{ env.EAGLE0_BUILD_DIR }}/editmode-test-results.xml
|
||||
retention-days: 3
|
||||
- name: Cleanup build directory
|
||||
if: always()
|
||||
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
|
||||
|
||||
@@ -23,7 +23,6 @@ bazel-bin
|
||||
bazel-eagle0*
|
||||
bazel-out
|
||||
bazel-testlogs
|
||||
.bazelrc.local
|
||||
.bazelrc.xcode
|
||||
.ijwb
|
||||
.clwb
|
||||
@@ -44,5 +43,3 @@ src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/Eagle0Protos/
|
||||
src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/Packages/com.unity.dedicated-server/
|
||||
node_modules/
|
||||
tools/map_generator/output/
|
||||
src/main/csharp/net/eagle0/clients/unity/eagle0/docs/generated/unity_asset_usage_audit_files.csv
|
||||
src/main/csharp/net/eagle0/clients/unity/eagle0/docs/generated/unity_lfs_asset_audit.csv
|
||||
|
||||
@@ -34,8 +34,8 @@ repos:
|
||||
hooks:
|
||||
- id: scalafmt
|
||||
name: scalafmt
|
||||
language: system
|
||||
entry: ./scripts/pre-commit-scalafmt.sh
|
||||
language: system
|
||||
entry: scalafmt -i -f
|
||||
types_or: ["scala"]
|
||||
- repo: local
|
||||
hooks:
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
# AGENTS.md
|
||||
|
||||
## CRITICAL BASH RULES (NEVER VIOLATE)
|
||||
|
||||
**NEVER chain bash commands.** Do not use `&&`, `||`, or `;` to combine commands. Each command must be a separate Bash tool call. Use parallel tool calls when commands are independent.
|
||||
|
||||
**NEVER prefix a command with `cd`.** Run `git`, `gh`, `bazel`, etc. directly from whatever the cwd already is. The cwd is a subdirectory of the worktree; `git`/`gh` find the repo via `.git` discovery and `bazel` finds the workspace via MODULE.bazel discovery — walking up from a subdir works fine. The shell resets cwd after every command anyway, so a `cd` never persists. (And `cd <dir> && <cmd>` violates the no-chaining rule above and forces an approval prompt every time.) Only `cd` in the rare case a tool genuinely cannot locate its root, and even then as its own Bash call, never a chain.
|
||||
|
||||
## CRITICAL GIT RULES (NEVER VIOLATE)
|
||||
|
||||
**NEVER use `git -C`.** Just run `git` directly from the cwd — it finds the repo via `.git` discovery. Do not `cd` to the repo root either (see the no-`cd` bash rule above).
|
||||
|
||||
**NEVER push directly to main/master.** No exceptions. Not for "small changes." Not for docs. Not ever.
|
||||
|
||||
**NEVER merge PRs.** You create PRs. The user merges them. No exceptions.
|
||||
|
||||
**ALWAYS use this workflow:**
|
||||
1. Create a feature branch from origin/main
|
||||
2. Commit to that branch
|
||||
3. Create a PR with `gh pr create`
|
||||
4. Wait for user to merge (DO NOT run `gh pr merge`)
|
||||
|
||||
If you catch yourself about to run `git push origin main` or `git push origin <branch>:main`, STOP. You are about to violate a critical rule. Create a PR instead.
|
||||
|
||||
If you catch yourself about to run `gh pr merge`, STOP. Only the user merges PRs.
|
||||
|
||||
## Worktree Usage
|
||||
|
||||
Prefer using the current worktree for small requested fixes. Create a separate worktree only when the user asks for one,
|
||||
when the current branch/state would make the change unsafe, or when isolating a large/risky change is clearly beneficial.
|
||||
|
||||
**NEVER leave the current worktree without explicit user approval.** Do not create, switch to, edit, commit in, or run
|
||||
git commands from another worktree unless the user has specifically asked for that worktree or approved the move. If the
|
||||
current worktree is unsafe because of dirty state, report that and ask before using a different worktree.
|
||||
|
||||
Codex may edit code and run git commands in the current worktree, even when unrelated files are dirty, as long as it:
|
||||
- does not revert, reset, clean, or overwrite unrelated changes
|
||||
- stages only the files relevant to the requested change
|
||||
- verifies the staged diff before committing
|
||||
- creates feature branches and PRs as usual
|
||||
- does not push to main/master or merge PRs
|
||||
|
||||
When the user says a Codex-created PR has been merged, delete the corresponding local branch and temporary worktree
|
||||
automatically, unless there is uncommitted work that would make cleanup unsafe.
|
||||
|
||||
## PR Timing
|
||||
|
||||
When code changes appear correct locally, create the PR promptly even if long-running builds or tests are still running.
|
||||
Opening the PR starts CI on the build machines, uploads results to the Bazel remote cache, and can speed up subsequent
|
||||
local validation. Continue monitoring any already-started local and CI validation after opening the PR.
|
||||
|
||||
## GitHub CLI PR Bodies
|
||||
|
||||
When creating or editing PR descriptions with multiline bodies, write the body to a temporary markdown file and pass it
|
||||
with `gh pr create --body-file <path>` or `gh pr edit <number> --body-file <path>`. Do not use multiline `$'...'`
|
||||
shell quoting for PR bodies; Codex's permission matching may treat that as a complex shell command instead of a clean
|
||||
`gh pr` invocation, causing unnecessary approval prompts.
|
||||
|
||||
Write these temporary body files, and any other scratch files needed for commands, under a known writable location such
|
||||
as `/private/tmp`, `$TMPDIR`, or the current repository workspace. Do not use protected paths that require additional
|
||||
user permission just to create or edit temporary files.
|
||||
|
||||
---
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Eagle0 is a multi-language gaming system combining strategic turn-based gameplay (Eagle) with tactical hex-based
|
||||
combat (Shardok). The system integrates LLM-based narrative generation and supports both human and AI players.
|
||||
|
||||
## Architecture
|
||||
|
||||
**Three-Tier Game System:**
|
||||
|
||||
- **Unity Client (C#)**: Real-time strategy game client with integrated tactical combat UI
|
||||
- **Eagle (Scala)**: Strategic layer managing turn-based gameplay, diplomacy, hero progression, and province control
|
||||
- **Shardok (C++)**: Tactical layer handling real-time hex-based combat simulation with performance-critical battle
|
||||
resolution
|
||||
|
||||
**Communication Flow:**
|
||||
|
||||
```
|
||||
Unity Client ↔ Eagle (gRPC streaming) ↔ Shardok (internal gRPC)
|
||||
```
|
||||
|
||||
**Key Entry Points:**
|
||||
|
||||
- `/src/main/csharp/net/eagle0/clients/unity/eagle0/` - Unity C# game client
|
||||
- `/src/main/scala/net/eagle0/eagle/Main.scala` - Eagle strategic game server
|
||||
- `/src/main/cpp/net/eagle0/shardok/shardok_server_main.cpp` - Shardok tactical server
|
||||
|
||||
**Protocol Buffer Architecture:**
|
||||
|
||||
- Extensive use of protobuf for type-safe communication
|
||||
- Separate packages: `api/` (client-facing), `internal/` (server state), `views/` (client projections)
|
||||
- Event sourcing pattern with immutable action history
|
||||
|
||||
## Essential Commands
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
# Build Eagle server (Scala strategic layer)
|
||||
bazel build //src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar
|
||||
|
||||
# Build Shardok server (C++ tactical layer)
|
||||
bazel build -c opt //src/main/cpp/net/eagle0/shardok:shardok-server
|
||||
|
||||
# Shardok server includes both AI algorithms
|
||||
bazel build //src/main/cpp/net/eagle0/shardok:shardok-server
|
||||
|
||||
# Build Unity/C# client
|
||||
./scripts/build_protos.sh # Protocol buffer generation for Unity
|
||||
./scripts/build_plugins.sh # Native plugins for all platforms
|
||||
./scripts/build_windows_plugin.sh # Windows-specific plugin build
|
||||
# Unity builds via CI: ci/github_actions/build_unity.sh
|
||||
```
|
||||
|
||||
### Running Services
|
||||
|
||||
```bash
|
||||
# Eagle server (port 40032)
|
||||
bazel run //src/main/scala/net/eagle0/eagle:eagle_server -- --eagle-grpc-port 40032
|
||||
# Or: ./scripts/eagle_run.sh
|
||||
|
||||
# Shardok server
|
||||
bazel run //src/main/cpp/net/eagle0/shardok:shardok-server --compilation_mode=opt
|
||||
# Or: ./scripts/shardok_run.sh
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
bazel test //src/test/... //src/main/go/...
|
||||
|
||||
# Component-specific tests
|
||||
bazel test //src/test/scala/... # Scala Eagle tests
|
||||
bazel test //src/test/cpp/... # C++ Shardok tests
|
||||
```
|
||||
|
||||
### Code Generation
|
||||
|
||||
```bash
|
||||
bazel run gazelle # Update Go build files
|
||||
./scripts/updateActionResultTypes.sh # Update protocol buffer mappings
|
||||
```
|
||||
|
||||
### Pre-Commit Checklist
|
||||
|
||||
**MANDATORY: Before running `git commit`, verify:**
|
||||
|
||||
1. **If you modified any BUILD.bazel file:** Run `bazel run gazelle` and stage any changes it makes
|
||||
2. **If you modified C++ or C# files:** Run `clang-format -i` on the modified files
|
||||
3. **If you modified Scala files:** scalafmt will run automatically via pre-commit hook
|
||||
4. **ALWAYS run the full test suite for your language changes before pushing:**
|
||||
- For Scala changes: `bazel test //src/test/scala/...`
|
||||
- For C++ changes: `bazel test //src/test/cpp/...`
|
||||
- This catches exhaustive pattern match errors and other compile-time failures that single-target builds miss
|
||||
|
||||
The pre-commit hook runs gazelle but only checks if it succeeds - it does NOT verify the BUILD files are in canonical format. The `gazelle_test` will fail if deps are not alphabetically sorted. **Always run gazelle manually after BUILD file changes.**
|
||||
|
||||
### Code Formatting
|
||||
|
||||
```bash
|
||||
# ALWAYS run clang-format after making any C++ or C# code changes
|
||||
clang-format -i <modified_files>
|
||||
|
||||
# Format all C++ files in a directory:
|
||||
find . -name "*.cpp" -o -name "*.hpp" | xargs clang-format -i
|
||||
|
||||
# Format all C# files in a directory:
|
||||
find . -name "*.cs" | xargs clang-format -i
|
||||
```
|
||||
|
||||
### Static Analysis
|
||||
|
||||
```bash
|
||||
# Run clang-tidy static analysis on C++ files
|
||||
# Note: This may show some header include errors but will still analyze the main file
|
||||
bazel run @llvm_toolchain//:clang-tidy -- --checks='readability-*,bugprone-*,clang-analyzer-*' <file_path> -- -I/Users/dancrosby/CodingProjects/github/eagle0 -std=c++23
|
||||
|
||||
# Example for AI files:
|
||||
bazel run @llvm_toolchain//:clang-tidy -- --checks='readability-*,bugprone-*,clang-analyzer-*' /Users/dancrosby/CodingProjects/github/eagle0/src/main/cpp/net/eagle0/shardok/ai/AIUnitScoreCalculator.cpp -- -I/Users/dancrosby/CodingProjects/github/eagle0 -std=c++23
|
||||
```
|
||||
|
||||
## AI Algorithm Selection
|
||||
|
||||
Eagle0 supports two AI algorithms for tactical combat decision-making:
|
||||
|
||||
### Iterative Deepening AI (Default)
|
||||
|
||||
The original minimax-based AI with sophisticated randomness handling:
|
||||
|
||||
- **Advantages**: Proven, sophisticated randomness evaluation, comprehensive lookahead
|
||||
- **Use cases**: Production builds, scenarios requiring precise evaluation
|
||||
- **Performance**: Single-threaded, thorough evaluation
|
||||
|
||||
### Monte Carlo Tree Search AI (MCTS)
|
||||
|
||||
Modern MCTS-based AI with multithreading support:
|
||||
|
||||
- **Advantages**: Multithreaded, better performance on modern CPUs, anytime algorithm
|
||||
- **Use cases**: Performance testing, scenarios requiring fast decisions
|
||||
- **Performance**: Multithreaded, adaptive depth based on time budget
|
||||
|
||||
### Switching Between Algorithms
|
||||
|
||||
The algorithm is selected at **runtime** via the ShardokAIClient constructor:
|
||||
|
||||
```cpp
|
||||
// Using Iterative Deepening AI (default)
|
||||
ShardokAIClient client(playerId, isDefender, hexMap, settings);
|
||||
// OR explicitly:
|
||||
ShardokAIClient client(playerId, isDefender, hexMap, settings, AIAlgorithmType::ITERATIVE_DEEPENING);
|
||||
|
||||
// Using MCTS AI
|
||||
ShardokAIClient client(playerId, isDefender, hexMap, settings, AIAlgorithmType::MCTS);
|
||||
```
|
||||
|
||||
```bash
|
||||
# Build the server (includes both AI algorithms)
|
||||
bazel build //src/main/cpp/net/eagle0/shardok:shardok-server
|
||||
|
||||
# Test both algorithms
|
||||
bazel test //src/test/cpp/net/eagle0/shardok/ai:ai_iterative_deepening_test
|
||||
bazel test //src/test/cpp/net/eagle0/shardok/ai:ai_mcts_test # If available
|
||||
|
||||
# Performance tests
|
||||
./scripts/ai_perf_test.sh # Uses whatever algorithm the server is configured to use
|
||||
```
|
||||
|
||||
Both implementations are compatible with all existing interfaces and produce the same `SearchResult` structure.
|
||||
|
||||
**Note**: Both implementations are documented in `src/main/cpp/net/eagle0/shardok/ai/AI_SCORING_SYSTEM.md`, including
|
||||
recommendations for improving MCTS randomness handling.
|
||||
|
||||
The AI algorithm selection is made at runtime when creating ShardokAIClient instances, allowing different AI strategies
|
||||
to be used for different players or game situations within the same server process.
|
||||
|
||||
## Language-Specific Patterns
|
||||
|
||||
**Scala (Strategic Layer):**
|
||||
|
||||
- Use `EngineImpl.scala` for core game logic modifications
|
||||
- Follow event sourcing pattern - all changes through immutable actions
|
||||
- gRPC streaming for real-time client updates via `EagleServiceImpl.scala`
|
||||
- LLM integration in `/common/llm_integration/` for narrative generation
|
||||
|
||||
**C++ (Tactical Layer):**
|
||||
|
||||
- Performance-critical combat in `ShardokEngine.hpp/.cpp`
|
||||
- FlatBuffers for efficient serialization in `/flatbuffer/` directory
|
||||
- AI systems in `/ai/` subdirectory with pluggable strategy selectors
|
||||
- Extensive unit testing with Google Test framework
|
||||
|
||||
**Protocol Buffers:**
|
||||
|
||||
- Three-layer structure: `api/` (client), `internal/` (server), `views/` (projections)
|
||||
- Use `shardok_internal_interface.proto` for Eagle-Shardok communication
|
||||
- Maintain backward compatibility when modifying existing messages
|
||||
|
||||
**C# (Unity Client):**
|
||||
|
||||
- Located in `/src/main/csharp/net/eagle0/clients/unity/eagle0/`
|
||||
- Uses Unity 6 (6000.4.10f1) with comprehensive protobuf integration (100+ .proto files)
|
||||
- Key components: `EagleConnection.cs` (gRPC client), `EagleGameController.cs` (main game logic)
|
||||
- Real-time bidirectional streaming with server via `PersistentClientConnection.cs`
|
||||
- Strategic map UI in `Assets/Eagle/`, tactical battle UI in `Assets/Shardok/`
|
||||
- Seamless transition between strategic gameplay and hex-based tactical combat
|
||||
- **NEVER add defensive null checks on Unity Inspector fields** - these hide configuration bugs. If a field isn't
|
||||
linked in the editor, it should throw a NullReferenceException so the problem is immediately obvious. Silently
|
||||
skipping code when a required field is null makes bugs harder to find.
|
||||
|
||||
**Go (Build Tools):**
|
||||
|
||||
- Build automation and code generation utilities
|
||||
- AWS S3 integration for deployment artifacts
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Comprehensive unit tests for both Scala and C++ components
|
||||
- Integration tests for Eagle-Shardok communication
|
||||
- Map validation tests ensure game content integrity
|
||||
- Use `GameSettings_test_utils.cpp` and `ShardokEngineBasedTestData.cpp` for C++ test helpers
|
||||
|
||||
### Scala Testing Patterns
|
||||
|
||||
**Use `inside()` instead of `asInstanceOf` for type matching in tests:**
|
||||
|
||||
Never use `asInstanceOf` in tests. Instead, use ScalaTest's `inside()` pattern for safe type matching:
|
||||
|
||||
```scala
|
||||
// BAD - don't do this
|
||||
val changedHero = result.changedHeroes.head.asInstanceOf[ChangedHeroC]
|
||||
changedHero.heroId shouldBe 19
|
||||
|
||||
// GOOD - use inside() pattern
|
||||
import org.scalatest.Inside.inside
|
||||
|
||||
inside(result.changedHeroes.head) { case changedHero: ChangedHeroC =>
|
||||
changedHero.heroId shouldBe 19
|
||||
changedHero.vigorChange shouldBe StatDelta(17.2)
|
||||
}
|
||||
```
|
||||
|
||||
The `inside()` pattern:
|
||||
- Provides better error messages when the type doesn't match
|
||||
- Is idiomatic ScalaTest
|
||||
- Works with pattern matching for more complex assertions
|
||||
|
||||
## Performance Testing
|
||||
|
||||
When making performance-related changes to the AI or engine:
|
||||
|
||||
```bash
|
||||
# 1. Commit your changes to a feature branch
|
||||
git checkout -b performance-improvement-feature
|
||||
git add . && git commit -m "Implement performance improvement"
|
||||
|
||||
# 2. Run performance tests multiple times on your branch to reduce noise
|
||||
for i in 1 2 3; do
|
||||
echo "=== Run $i ==="
|
||||
./scripts/ai_perf_test.sh 2>&1 | grep -A 20 "AI Search Performance Summary"
|
||||
done
|
||||
# Save or note the results
|
||||
|
||||
# 3. Switch to main branch and run the same tests
|
||||
git checkout main
|
||||
for i in 1 2 3; do
|
||||
echo "=== Run $i ==="
|
||||
./scripts/ai_perf_test.sh 2>&1 | grep -A 20 "AI Search Performance Summary"
|
||||
done
|
||||
|
||||
# 4. Compare the results between your branch and main
|
||||
# Key metrics to compare:
|
||||
# - Commands evaluated at each depth (e.g., "Depth 3: 169/523 commands")
|
||||
# - Average search depth achieved
|
||||
# - Completion rates at each depth
|
||||
```
|
||||
|
||||
**Important notes:**
|
||||
|
||||
- Run tests multiple times (3-5) to account for performance variance
|
||||
- Focus on commands evaluated at each depth rather than total commands
|
||||
- Commands at different depths aren't directly comparable (depth 3 is more valuable than depth 2)
|
||||
- **Always test performance changes** - what seems like an optimization may sometimes have unexpected overhead or
|
||||
behavior changes.
|
||||
|
||||
## Troubleshooting Scala Build Errors
|
||||
|
||||
### MissingType Errors
|
||||
|
||||
When you see errors like:
|
||||
```
|
||||
dotty.tools.dotc.core.MissingType: Cannot resolve reference to type net.eagle0.eagle.internal.game_state.type.GameState
|
||||
```
|
||||
|
||||
**This is NOT a Scala compiler crash.** This is a missing dependency in BUILD.bazel.
|
||||
|
||||
**How to fix:**
|
||||
1. Identify the missing type from the error message (e.g., `game_state.GameState`)
|
||||
2. Find the Bazel target that provides this type (e.g., `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`)
|
||||
3. Add it to the `deps` of the failing target
|
||||
4. If the type appears in a public method signature, also add it to `exports` so downstream targets can see it
|
||||
|
||||
**Common pattern:** When adding a method to a class that takes or returns a proto type, the proto dependency often needs to be added to both `deps` AND `exports`.
|
||||
|
||||
### Bazel Clean
|
||||
|
||||
**NEVER run `bazel clean` without asking first.** It rarely fixes actual issues and wastes significant rebuild time. The issues that seem like they need `bazel clean` are usually:
|
||||
- Missing imports in Scala code
|
||||
- Missing dependencies in BUILD.bazel
|
||||
- Missing exports for types used in public signatures
|
||||
|
||||
## Game Content
|
||||
|
||||
**Maps:** `.e0mj` files in `/src/main/resources/net/eagle0/shardok/maps/`
|
||||
**Configuration:** Game parameters in `/src/main/resources/net/eagle0/eagle/game_parameters.json`
|
||||
**Data Files:** TSV format for battalions, heroes, and other game data
|
||||
|
||||
**NEVER modify hero names.** Do not change names in `heroes.tsv`, `generated_heroes`, or any other hero data files. The names are carefully chosen and are not to be altered.
|
||||
|
||||
## Deployment
|
||||
|
||||
- Bazel handles multi-language builds and dependencies
|
||||
- CI/CD via GitHub Actions with platform-specific build scripts in `/ci/github_actions/`
|
||||
- Docker containerization available via `ci/eagle_run.Dockerfile`
|
||||
- Always run "bazel run //:gazelle" after editing any BUILD.bazel files
|
||||
- *ALWAYS ALWAYS* run "bazel run gazelle" after any change that modifies a BUILD.bazel file
|
||||
@@ -4,11 +4,9 @@
|
||||
|
||||
**NEVER chain bash commands.** Do not use `&&`, `||`, or `;` to combine commands. Each command must be a separate Bash tool call. Use parallel tool calls when commands are independent.
|
||||
|
||||
**NEVER prefix a command with `cd`.** Run `git`, `gh`, `bazel`, etc. directly from whatever the cwd already is. The cwd is a subdirectory of the worktree; `git`/`gh` find the repo via `.git` discovery and `bazel` finds the workspace via MODULE.bazel discovery — walking up from a subdir works fine. The shell resets cwd after every command anyway, so a `cd` never persists. (And `cd <dir> && <cmd>` violates the no-chaining rule above and forces an approval prompt every time.) Only `cd` in the rare case a tool genuinely cannot locate its root, and even then as its own Bash call, never a chain.
|
||||
|
||||
## CRITICAL GIT RULES (NEVER VIOLATE)
|
||||
|
||||
**NEVER use `git -C`.** Just run `git` directly from the cwd — it finds the repo via `.git` discovery. Do not `cd` to the repo root either (see the no-`cd` bash rule above).
|
||||
**NEVER use `git -C`.** Instead, cd to the repo root and run git commands from there.
|
||||
|
||||
**NEVER push directly to main/master.** No exceptions. Not for "small changes." Not for docs. Not ever.
|
||||
|
||||
@@ -228,7 +226,7 @@ to be used for different players or game situations within the same server proce
|
||||
**C# (Unity Client):**
|
||||
|
||||
- Located in `/src/main/csharp/net/eagle0/clients/unity/eagle0/`
|
||||
- Uses Unity 6 (6000.4.10f1) with comprehensive protobuf integration (100+ .proto files)
|
||||
- Uses Unity 6 (6000.0.32f1) with comprehensive protobuf integration (100+ .proto files)
|
||||
- Key components: `EagleConnection.cs` (gRPC client), `EagleGameController.cs` (main game logic)
|
||||
- Real-time bidirectional streaming with server via `PersistentClientConnection.cs`
|
||||
- Strategic map UI in `Assets/Eagle/`, tactical battle UI in `Assets/Shardok/`
|
||||
@@ -352,4 +350,4 @@ dotty.tools.dotc.core.MissingType: Cannot resolve reference to type net.eagle0.e
|
||||
- CI/CD via GitHub Actions with platform-specific build scripts in `/ci/github_actions/`
|
||||
- Docker containerization available via `ci/eagle_run.Dockerfile`
|
||||
- Always run "bazel run //:gazelle" after editing any BUILD.bazel files
|
||||
- *ALWAYS ALWAYS* run "bazel run gazelle" after any change that modifies a BUILD.bazel file
|
||||
- *ALWAYS ALWAYS* run "bazel run gazelle" after any change that modifies a BUILD.bazel file
|
||||
+41
-59
@@ -3,29 +3,25 @@ module(name = "net_eagle0")
|
||||
# Version constants
|
||||
SCALA_VERSION = "3.7.4"
|
||||
|
||||
NETTY_VERSION = "4.1.135.Final"
|
||||
NETTY_VERSION = "4.1.127.Final"
|
||||
|
||||
SCALAPB_VERSION = "1.0.0-alpha.5"
|
||||
SCALAPB_VERSION = "1.0.0-alpha.1"
|
||||
|
||||
SCALAPB_JSON4S_VERSION = "1.0.0-alpha.1"
|
||||
|
||||
AWS_SDK_VERSION = "2.46.7"
|
||||
|
||||
SLF4J_VERSION = "2.0.18"
|
||||
AWS_SDK_VERSION = "2.41.18"
|
||||
|
||||
#
|
||||
# Core Build Tools
|
||||
#
|
||||
|
||||
bazel_dep(name = "bazel_skylib", version = "1.9.0")
|
||||
bazel_dep(name = "platforms", version = "1.1.0")
|
||||
bazel_dep(name = "platforms", version = "1.0.0")
|
||||
bazel_dep(name = "rules_pkg", version = "1.2.0")
|
||||
|
||||
#
|
||||
# Language Support - Scala
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_scala", version = "7.2.5")
|
||||
bazel_dep(name = "rules_scala", version = "7.2.1")
|
||||
|
||||
scala_config = use_extension(
|
||||
"@rules_scala//scala/extensions:config.bzl",
|
||||
@@ -45,21 +41,21 @@ scala_deps.scala_proto()
|
||||
# Language Support - C++
|
||||
#
|
||||
|
||||
bazel_dep(name = "toolchains_llvm", version = "1.7.0")
|
||||
bazel_dep(name = "toolchains_llvm", version = "1.6.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.8",
|
||||
llvm_version = "20.1.4",
|
||||
)
|
||||
|
||||
# Cross-compilation toolchain (macOS -> Linux x86_64)
|
||||
# Uses the same LLVM distribution but with a Linux sysroot
|
||||
llvm.toolchain(
|
||||
name = "llvm_toolchain_linux",
|
||||
llvm_version = "20.1.8",
|
||||
llvm_version = "20.1.4",
|
||||
)
|
||||
|
||||
# Linux x86_64 sysroot for cross-compilation
|
||||
@@ -72,7 +68,7 @@ llvm.sysroot(
|
||||
# Cross-compilation toolchain (macOS -> Linux ARM64)
|
||||
llvm.toolchain(
|
||||
name = "llvm_toolchain_linux_arm64",
|
||||
llvm_version = "20.1.8",
|
||||
llvm_version = "20.1.4",
|
||||
)
|
||||
|
||||
# Linux ARM64 sysroot for cross-compilation
|
||||
@@ -81,6 +77,7 @@ llvm.sysroot(
|
||||
label = "@linux_sysroot_arm64//sysroot",
|
||||
targets = ["linux-aarch64"],
|
||||
)
|
||||
|
||||
use_repo(llvm, "llvm_toolchain", "llvm_toolchain_linux", "llvm_toolchain_linux_arm64")
|
||||
|
||||
# Download the Linux sysroots (Ubuntu 24.04 Noble for C++23 support)
|
||||
@@ -106,11 +103,11 @@ sysroot(
|
||||
# Language Support - Go
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_go", version = "0.61.1", repo_name = "io_bazel_rules_go")
|
||||
bazel_dep(name = "gazelle", version = "0.51.3", repo_name = "bazel_gazelle")
|
||||
bazel_dep(name = "rules_go", version = "0.59.0", repo_name = "io_bazel_rules_go")
|
||||
bazel_dep(name = "gazelle", version = "0.47.0", repo_name = "bazel_gazelle")
|
||||
|
||||
go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk")
|
||||
go_sdk.download(version = "1.25.11")
|
||||
go_sdk.download(version = "1.23.3")
|
||||
use_repo(go_sdk, "go_default_sdk")
|
||||
|
||||
go_deps = use_extension("@bazel_gazelle//:extensions.bzl", "go_deps")
|
||||
@@ -133,7 +130,7 @@ use_repo(
|
||||
# Language Support - Rust
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_rust", version = "0.70.0")
|
||||
bazel_dep(name = "rules_rust", version = "0.68.1")
|
||||
|
||||
rust = use_extension("@rules_rust//rust:extensions.bzl", "rust")
|
||||
rust.toolchain(edition = "2021")
|
||||
@@ -153,7 +150,7 @@ use_repo(crate, "map_generator_crates")
|
||||
# Platform Support - Apple/iOS
|
||||
#
|
||||
|
||||
bazel_dep(name = "apple_support", version = "1.24.5", repo_name = "build_bazel_apple_support")
|
||||
bazel_dep(name = "apple_support", version = "1.24.1", repo_name = "build_bazel_apple_support")
|
||||
bazel_dep(name = "rules_apple", version = "4.3.3", repo_name = "build_bazel_rules_apple")
|
||||
|
||||
# Register Apple CC toolchain for Objective-C compilation
|
||||
@@ -167,7 +164,7 @@ use_repo(apple_cc_configure, "local_config_apple_cc", "local_config_apple_cc_too
|
||||
# Protocol Buffers & RPC
|
||||
#
|
||||
|
||||
bazel_dep(name = "protobuf", version = "33.6", repo_name = "com_google_protobuf")
|
||||
bazel_dep(name = "protobuf", version = "33.5", repo_name = "com_google_protobuf")
|
||||
|
||||
# Use pre-built protoc binaries instead of compiling from source.
|
||||
# See: https://protobuf.dev/reference/cpp/cpp-generated/#invocation
|
||||
@@ -186,7 +183,7 @@ use_repo(
|
||||
)
|
||||
|
||||
bazel_dep(name = "rules_proto", version = "7.1.0")
|
||||
bazel_dep(name = "rules_cc", version = "0.2.19")
|
||||
bazel_dep(name = "rules_cc", version = "0.2.14")
|
||||
bazel_dep(name = "grpc", version = "1.78.0")
|
||||
|
||||
# Patch grpc 1.78.0 to fix rules_python incompatibility: grpc requests Python
|
||||
@@ -200,7 +197,7 @@ single_version_override(
|
||||
version = "1.78.0",
|
||||
)
|
||||
|
||||
bazel_dep(name = "grpc-java", version = "1.78.0.bcr.1")
|
||||
bazel_dep(name = "grpc-java", version = "1.78.0")
|
||||
bazel_dep(name = "rules_proto_grpc_csharp", version = "5.8.0")
|
||||
bazel_dep(name = "flatbuffers", version = "25.12.19")
|
||||
|
||||
@@ -208,13 +205,13 @@ bazel_dep(name = "flatbuffers", version = "25.12.19")
|
||||
# Testing
|
||||
#
|
||||
|
||||
bazel_dep(name = "googletest", version = "1.17.0.bcr.2")
|
||||
bazel_dep(name = "googletest", version = "1.17.0")
|
||||
|
||||
#
|
||||
# Container Images (OCI)
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_oci", version = "2.3.0")
|
||||
bazel_dep(name = "rules_oci", version = "2.2.7")
|
||||
bazel_dep(name = "aspect_bazel_lib", version = "2.22.5")
|
||||
|
||||
oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
|
||||
@@ -264,7 +261,7 @@ use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_25_jd
|
||||
# Java/Scala Dependencies
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_java", version = "9.6.1")
|
||||
bazel_dep(name = "rules_java", version = "9.3.0")
|
||||
bazel_dep(name = "rules_jvm_external", version = "6.10")
|
||||
|
||||
maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")
|
||||
@@ -284,7 +281,7 @@ maven.install(
|
||||
|
||||
# ScalaPB
|
||||
"com.thesamet.scalapb:lenses_3:%s" % SCALAPB_VERSION,
|
||||
"com.thesamet.scalapb:scalapb-json4s_3:%s" % SCALAPB_JSON4S_VERSION,
|
||||
"com.thesamet.scalapb:scalapb-json4s_3:%s" % SCALAPB_VERSION,
|
||||
"com.thesamet.scalapb:scalapb-runtime_3:%s" % SCALAPB_VERSION,
|
||||
"com.thesamet.scalapb:scalapb-runtime-grpc_3:%s" % SCALAPB_VERSION,
|
||||
"com.thesamet.scalapb:compilerplugin_3:%s" % SCALAPB_VERSION,
|
||||
@@ -296,10 +293,7 @@ maven.install(
|
||||
"org.json4s:json4s-native_3:4.1.0-M8",
|
||||
|
||||
# Testing
|
||||
"org.scalamock:scalamock_3:7.5.5",
|
||||
|
||||
# Developer tools
|
||||
"org.scalameta:scalafmt-cli_2.13:3.11.1",
|
||||
"org.scalamock:scalamock_3:7.4.1",
|
||||
|
||||
# AWS SDK
|
||||
"software.amazon.awssdk:s3-transfer-manager:%s" % AWS_SDK_VERSION,
|
||||
@@ -311,69 +305,58 @@ maven.install(
|
||||
"software.amazon.awssdk:http-client-spi:%s" % AWS_SDK_VERSION,
|
||||
|
||||
# AWS Lambda
|
||||
"com.amazonaws:aws-lambda-java-core:1.4.0",
|
||||
"com.amazonaws:aws-lambda-java-events:3.16.1",
|
||||
"com.amazonaws:aws-lambda-java-core:1.2.3",
|
||||
"com.amazonaws:aws-lambda-java-events:3.13.0",
|
||||
|
||||
# Logging
|
||||
"org.slf4j:slf4j-api:%s" % SLF4J_VERSION,
|
||||
"org.slf4j:slf4j-simple:%s" % SLF4J_VERSION,
|
||||
"org.slf4j:slf4j-api:2.0.16",
|
||||
"org.slf4j:slf4j-simple:2.0.16",
|
||||
|
||||
# Other
|
||||
"org.reactivestreams:reactive-streams:1.0.4",
|
||||
"javax.xml.bind:jaxb-api:2.3.1",
|
||||
|
||||
# OkHttp (for SSE with read timeout support, OAuth HTTP calls)
|
||||
"com.squareup.okhttp3:okhttp-jvm:5.4.0",
|
||||
"com.squareup.okhttp3:okhttp-sse:5.4.0",
|
||||
"com.squareup.okhttp3:okhttp:4.12.0",
|
||||
"com.squareup.okhttp3:okhttp-sse:4.12.0",
|
||||
|
||||
# JWT (for OAuth token handling)
|
||||
"com.nimbusds:nimbus-jose-jwt:10.9.1",
|
||||
"com.nimbusds:nimbus-jose-jwt:9.37.3",
|
||||
|
||||
# Error tracking
|
||||
"io.sentry:sentry:8.43.2",
|
||||
"io.sentry:sentry:8.31.0",
|
||||
|
||||
# SQLite for client text storage
|
||||
"org.xerial:sqlite-jdbc:3.53.2.0",
|
||||
|
||||
# Postgres for production history benchmarking and migration work
|
||||
"org.postgresql:postgresql:42.7.11",
|
||||
"org.xerial:sqlite-jdbc:3.46.1.0",
|
||||
],
|
||||
duplicate_version_warning = "error",
|
||||
fail_if_repin_required = True,
|
||||
lock_file = "//:maven_install.json",
|
||||
repositories = [
|
||||
"https://repo.maven.apache.org/maven2",
|
||||
"https://repo1.maven.org/maven2",
|
||||
],
|
||||
)
|
||||
|
||||
# json4s' Scala 3 reflection path can load scala.quoted.staging at runtime, but
|
||||
# its Maven metadata does not bring the staging jar transitively.
|
||||
maven.artifact(
|
||||
artifact = "scala3-staging_3",
|
||||
exclusions = ["org.scala-lang:scala3-compiler_3"],
|
||||
group = "org.scala-lang",
|
||||
version = SCALA_VERSION,
|
||||
)
|
||||
|
||||
# Force specific versions for dependencies with conflicts between grpc-java and protobuf
|
||||
maven.artifact(
|
||||
artifact = "gson",
|
||||
force_version = True,
|
||||
group = "com.google.code.gson",
|
||||
version = "2.14.0",
|
||||
version = "2.11.0",
|
||||
)
|
||||
maven.artifact(
|
||||
artifact = "error_prone_annotations",
|
||||
force_version = True,
|
||||
group = "com.google.errorprone",
|
||||
version = "2.50.0",
|
||||
version = "2.30.0",
|
||||
)
|
||||
maven.artifact(
|
||||
artifact = "guava",
|
||||
force_version = True,
|
||||
group = "com.google.guava",
|
||||
version = "33.6.0-android",
|
||||
version = "33.3.1-android",
|
||||
)
|
||||
|
||||
use_repo(maven, "maven", "unpinned_maven")
|
||||
|
||||
#
|
||||
@@ -381,7 +364,6 @@ use_repo(maven, "maven", "unpinned_maven")
|
||||
#
|
||||
|
||||
http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
|
||||
|
||||
http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file")
|
||||
|
||||
# GTL (for parallel_hashmap)
|
||||
@@ -434,24 +416,24 @@ http_archive(
|
||||
# Fallback: busybox.net (can be unreliable/slow)
|
||||
http_file(
|
||||
name = "busybox_x86_64",
|
||||
downloaded_file_path = "busybox",
|
||||
executable = True,
|
||||
sha256 = "6e123e7f3202a8c1e9b1f94d8941580a25135382b99e8d3e34fb858bba311348",
|
||||
urls = [
|
||||
"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/busybox/busybox-1.35.0-x86_64-linux-musl",
|
||||
"https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox",
|
||||
],
|
||||
downloaded_file_path = "busybox",
|
||||
executable = True,
|
||||
)
|
||||
|
||||
http_file(
|
||||
name = "busybox_aarch64",
|
||||
downloaded_file_path = "busybox",
|
||||
executable = True,
|
||||
sha256 = "141adb1b625a6f44c4b114f76b4387b4ea4f7ab802b88eb40e0d2f6adcccb1c3",
|
||||
urls = [
|
||||
# TODO: Upload aarch64 binary to GitHub release when needed
|
||||
"https://busybox.net/downloads/binaries/1.35.0-aarch64-linux-musl/busybox",
|
||||
],
|
||||
downloaded_file_path = "busybox",
|
||||
executable = True,
|
||||
)
|
||||
|
||||
# LLVM MinGW toolchain for Windows cross-compilation from macOS
|
||||
|
||||
Generated
+201
-226
File diff suppressed because one or more lines are too long
+7
-38
@@ -1,6 +1,5 @@
|
||||
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load", "oci_push")
|
||||
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
|
||||
load("//ci:jar_split.bzl", "jar_split")
|
||||
|
||||
#
|
||||
# Deployment artifacts (tools needed on the host, not in containers)
|
||||
@@ -51,37 +50,11 @@ pkg_tar(
|
||||
# Push: bazel run //ci:eagle_server_push
|
||||
#
|
||||
|
||||
# Split the Eagle server runtime classpath into a stable third-party layer
|
||||
# (Scala stdlib, gRPC, Netty, ScalaPB, AWS SDK, ...) and a small first-party
|
||||
# layer that changes every commit. Most pushes then only re-upload the small
|
||||
# app layer instead of the whole ~150-300MB fat JAR.
|
||||
jar_split(
|
||||
name = "eagle_server_jars",
|
||||
binary = "//src/main/scala/net/eagle0/eagle:eagle_server",
|
||||
)
|
||||
|
||||
filegroup(
|
||||
name = "eagle_server_deps_jars",
|
||||
srcs = [":eagle_server_jars"],
|
||||
output_group = "deps",
|
||||
)
|
||||
|
||||
filegroup(
|
||||
name = "eagle_server_app_jars",
|
||||
srcs = [":eagle_server_jars"],
|
||||
output_group = "app",
|
||||
)
|
||||
|
||||
# Package the deploy JAR
|
||||
pkg_tar(
|
||||
name = "eagle_server_deps_layer",
|
||||
srcs = [":eagle_server_deps_jars"],
|
||||
package_dir = "/app/lib/deps",
|
||||
)
|
||||
|
||||
pkg_tar(
|
||||
name = "eagle_server_app_layer",
|
||||
srcs = [":eagle_server_app_jars"],
|
||||
package_dir = "/app/lib/app",
|
||||
name = "eagle_server_jar_layer",
|
||||
srcs = ["//src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar"],
|
||||
package_dir = "/app",
|
||||
)
|
||||
|
||||
# Package the game resources needed at runtime
|
||||
@@ -109,11 +82,8 @@ oci_image(
|
||||
"-XX:+UnlockDiagnosticVMOptions",
|
||||
"-XX:+DebugNonSafepoints", # Required for JFR to see through inlined methods
|
||||
"-XX:FlightRecorderOptions=stackdepth=256",
|
||||
# Classpath glob is expanded by the JVM itself (exec-form, no shell).
|
||||
# Deps dir first keeps third-party precedence; app last shadows nothing.
|
||||
"-cp",
|
||||
"/app/lib/deps/*:/app/lib/app/*",
|
||||
"net.eagle0.eagle.Main",
|
||||
"-jar",
|
||||
"/app/eagle_server_deploy.jar",
|
||||
],
|
||||
env = {
|
||||
"JAVA_OPTS": "-Xmx2g -XX:+UseG1GC",
|
||||
@@ -121,8 +91,7 @@ oci_image(
|
||||
exposed_ports = ["40032/tcp"],
|
||||
tars = [
|
||||
":busybox_layer",
|
||||
":eagle_server_deps_layer",
|
||||
":eagle_server_app_layer",
|
||||
":eagle_server_jar_layer",
|
||||
":eagle_resources_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
|
||||
@@ -39,12 +39,6 @@ if [ $UNITY_EXIT_CODE -ne 0 ]; then
|
||||
exit $UNITY_EXIT_CODE
|
||||
fi
|
||||
|
||||
if [ ! -f "$LOG_PATH" ]; then
|
||||
echo ""
|
||||
echo "ERROR: Unity exited successfully but did not write an editor log at $LOG_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fail the build if any prefab references are broken — this produces a player
|
||||
# that launches but has null Inspector fields, which is hard to debug.
|
||||
if grep -q "Missing Prefab" "$LOG_PATH"; then
|
||||
|
||||
@@ -39,12 +39,6 @@ if [ $UNITY_EXIT_CODE -ne 0 ]; then
|
||||
exit $UNITY_EXIT_CODE
|
||||
fi
|
||||
|
||||
if [ ! -f "$LOG_PATH" ]; then
|
||||
echo ""
|
||||
echo "ERROR: Unity exited successfully but did not write an editor log at $LOG_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fail the build if any prefab references are broken — this produces a player
|
||||
# that launches but has null Inspector fields, which is hard to debug.
|
||||
if grep -q "Missing Prefab" "$LOG_PATH"; then
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH="${COMMON_PATHS}:${PATH}"
|
||||
|
||||
write_developer_dir_override() {
|
||||
if [ "$(uname -s)" != "Darwin" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local developer_dir=""
|
||||
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
|
||||
developer_dir="/Applications/Xcode.app/Contents/Developer"
|
||||
elif [ -d "/Library/Developer/CommandLineTools" ]; then
|
||||
developer_dir="/Library/Developer/CommandLineTools"
|
||||
else
|
||||
developer_dir="$(xcode-select -p 2>/dev/null || true)"
|
||||
fi
|
||||
|
||||
if [ -z "$developer_dir" ] || [ ! -d "$developer_dir" ]; then
|
||||
echo "ERROR: no usable Apple developer directory found"
|
||||
echo "Install Command Line Tools with: xcode-select --install"
|
||||
echo "Full Xcode is only required for workflows that build/sign Apple apps."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export DEVELOPER_DIR="$developer_dir"
|
||||
if [ -f .bazelrc.local ] && [ -z "${CI:-}" ]; then
|
||||
echo "Keeping existing .bazelrc.local outside CI"
|
||||
else
|
||||
{
|
||||
echo "# Generated by ci/github_actions/ensure_bazel_installed.sh; do not edit."
|
||||
printf 'common:macos --repo_env=DEVELOPER_DIR=%s\n' "$developer_dir"
|
||||
} > .bazelrc.local
|
||||
fi
|
||||
|
||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||
echo "DEVELOPER_DIR=$developer_dir" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
echo "Using Apple developer directory at $developer_dir"
|
||||
}
|
||||
|
||||
write_developer_dir_override
|
||||
|
||||
if [ -n "${GITHUB_PATH:-}" ]; then
|
||||
for path in /opt/homebrew/bin /usr/local/bin; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if command -v bazel >/dev/null 2>&1; then
|
||||
echo "Using bazel at $(command -v bazel)"
|
||||
bazel --version
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v bazelisk >/dev/null 2>&1; then
|
||||
if ! command -v brew >/dev/null 2>&1; then
|
||||
echo "ERROR: neither bazel nor bazelisk is on PATH, and Homebrew is unavailable"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Installing bazelisk with Homebrew"
|
||||
brew install bazelisk
|
||||
fi
|
||||
|
||||
BAZEL_BIN_DIR="${RUNNER_TEMP:-/tmp}/bazel-bin"
|
||||
mkdir -p "$BAZEL_BIN_DIR"
|
||||
ln -sf "$(command -v bazelisk)" "$BAZEL_BIN_DIR/bazel"
|
||||
|
||||
if [ -n "${GITHUB_PATH:-}" ]; then
|
||||
echo "$BAZEL_BIN_DIR" >> "$GITHUB_PATH"
|
||||
fi
|
||||
|
||||
export PATH="$BAZEL_BIN_DIR:$PATH"
|
||||
echo "Using bazelisk as bazel at $BAZEL_BIN_DIR/bazel"
|
||||
bazel --version
|
||||
@@ -34,7 +34,6 @@ echo "Platform: ${PLATFORM}"
|
||||
# Check if Unity is installed and has required modules
|
||||
check_modules_installed() {
|
||||
local unity_path="${UNITY_INSTALL_PATH}/${UNITY_VERSION}"
|
||||
local unity_app_contents_path="${unity_path}/Unity.app/Contents"
|
||||
|
||||
if [ ! -d "$unity_path" ]; then
|
||||
return 1
|
||||
@@ -50,8 +49,7 @@ check_modules_installed() {
|
||||
;;
|
||||
mac)
|
||||
# Mac IL2CPP module
|
||||
if [ ! -d "${unity_app_contents_path}/PlaybackEngines/MacStandaloneSupport/Source/Player/MacPlayer/GameAssembly.xcodeproj" ] && \
|
||||
[ ! -d "${unity_path}/PlaybackEngines/MacStandaloneSupport/Variations/macos_development_il2cpp" ] && \
|
||||
if [ ! -d "${unity_path}/PlaybackEngines/MacStandaloneSupport/Variations/macos_development_il2cpp" ] && \
|
||||
[ ! -d "${unity_path}/PlaybackEngines/MacStandaloneSupport" ]; then
|
||||
echo "✗ Mac IL2CPP module not installed for Unity ${UNITY_VERSION}"
|
||||
return 1
|
||||
@@ -71,8 +69,7 @@ check_modules_installed() {
|
||||
echo "✗ iOS module missing"
|
||||
missing=1
|
||||
fi
|
||||
if [ ! -d "${unity_app_contents_path}/PlaybackEngines/MacStandaloneSupport/Source/Player/MacPlayer/GameAssembly.xcodeproj" ] && \
|
||||
[ ! -d "${unity_path}/PlaybackEngines/MacStandaloneSupport" ]; then
|
||||
if [ ! -d "${unity_path}/PlaybackEngines/MacStandaloneSupport" ]; then
|
||||
echo "✗ Mac module missing"
|
||||
missing=1
|
||||
fi
|
||||
@@ -194,10 +191,8 @@ echo "Running: ${INSTALL_CMD[*]}"
|
||||
echo ""
|
||||
|
||||
# Capture output to check for "already installed" messages
|
||||
set +e
|
||||
OUTPUT=$("${INSTALL_CMD[@]}" 2>&1)
|
||||
OUTPUT=$("${INSTALL_CMD[@]}" 2>&1) || true
|
||||
EXIT_CODE=$?
|
||||
set -e
|
||||
echo "$OUTPUT"
|
||||
|
||||
# Check if modules are already installed (Unity Hub returns error but modules are present)
|
||||
@@ -224,13 +219,13 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify installation and requested modules
|
||||
# Verify installation
|
||||
echo ""
|
||||
if check_modules_installed; then
|
||||
echo "✓ Verified: Unity ${UNITY_VERSION} is now installed with ${PLATFORM} support"
|
||||
if [ -d "${UNITY_INSTALL_PATH}/${UNITY_VERSION}" ]; then
|
||||
echo "✓ Verified: Unity ${UNITY_VERSION} is now installed"
|
||||
exit 0
|
||||
else
|
||||
echo "✗ Unity ${UNITY_VERSION} installation with ${PLATFORM} support could not be verified"
|
||||
echo "✗ Unity ${UNITY_VERSION} installation could not be verified"
|
||||
echo " Expected path: ${UNITY_INSTALL_PATH}/${UNITY_VERSION}"
|
||||
echo ""
|
||||
echo "The installation may still be in progress, or may require manual intervention."
|
||||
|
||||
@@ -11,46 +11,16 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH="${COMMON_PATHS}:${PATH}"
|
||||
|
||||
MAX_ATTEMPTS=5
|
||||
RETRY_DELAY=15
|
||||
|
||||
if [ -n "${GITHUB_PATH:-}" ]; then
|
||||
for path in /opt/homebrew/bin /usr/local/bin; do
|
||||
if [ -d "$path" ]; then
|
||||
echo "$path" >> "$GITHUB_PATH"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if ! git lfs version >/dev/null 2>&1; then
|
||||
if ! command -v brew >/dev/null 2>&1; then
|
||||
echo "ERROR: git-lfs is unavailable, and Homebrew is not on PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Installing git-lfs with Homebrew"
|
||||
brew install git-lfs
|
||||
fi
|
||||
|
||||
echo "Using git-lfs at $(command -v git-lfs)"
|
||||
git lfs version
|
||||
|
||||
git lfs install --force
|
||||
git lfs install
|
||||
|
||||
echo "LFS objects before pull:"
|
||||
git lfs ls-files | wc -l
|
||||
|
||||
GIT_LFS_PULL=(git lfs pull)
|
||||
if [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
BASIC_AUTH=$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')
|
||||
GIT_LFS_PULL=(git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic $BASIC_AUTH" lfs pull)
|
||||
fi
|
||||
|
||||
for i in $(seq 1 $MAX_ATTEMPTS); do
|
||||
if "${GIT_LFS_PULL[@]}" "$@"; then
|
||||
if git lfs pull "$@"; then
|
||||
echo "LFS objects after pull:"
|
||||
git lfs ls-files | wc -l
|
||||
exit 0
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
# Read Unity version from project file
|
||||
UNITY_VERSION=$(grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //')
|
||||
|
||||
# Use runner-specific build directory if EAGLE0_BUILD_DIR is set, otherwise default
|
||||
BUILD_BASE="${EAGLE0_BUILD_DIR:-/tmp/eagle0}"
|
||||
|
||||
WORKSPACE=$(pwd)
|
||||
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
|
||||
LOG_PATH="${BUILD_BASE}/editor_editmode_tests.log"
|
||||
RESULTS_PATH="${BUILD_BASE}/editmode-test-results.xml"
|
||||
|
||||
mkdir -p "$BUILD_BASE"
|
||||
|
||||
echo "Building protos"
|
||||
./scripts/build_protos.sh
|
||||
|
||||
echo "Running Unity EditMode tests"
|
||||
|
||||
set +e
|
||||
"${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity" \
|
||||
-nographics \
|
||||
-batchmode \
|
||||
-quit \
|
||||
-executeMethod eagle0.Tests.DynamicTextBindingTestRunner.Run \
|
||||
-testResults "$RESULTS_PATH" \
|
||||
-logFile "$LOG_PATH" \
|
||||
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
|
||||
UNITY_EXIT_CODE=$?
|
||||
set -e
|
||||
|
||||
if [ $UNITY_EXIT_CODE -ne 0 ]; then
|
||||
echo ""
|
||||
echo "Unity EditMode tests failed with exit code $UNITY_EXIT_CODE"
|
||||
echo "=== Unity EditMode Test Log (last 200 lines) ==="
|
||||
tail -200 "$LOG_PATH" || echo "Could not read log file at $LOG_PATH"
|
||||
echo "=== End of Unity EditMode Test Log ==="
|
||||
exit $UNITY_EXIT_CODE
|
||||
fi
|
||||
|
||||
if [ ! -f "$LOG_PATH" ]; then
|
||||
echo ""
|
||||
echo "ERROR: Unity exited successfully but did not write an editor log at $LOG_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$RESULTS_PATH" ]; then
|
||||
echo ""
|
||||
echo "ERROR: Unity exited successfully but did not write test results at $RESULTS_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Unity EditMode tests complete"
|
||||
@@ -19,18 +19,6 @@ SERVER_DATA="$UNITY_PROJECT/ServerData/$BUILD_TARGET"
|
||||
DO_ENDPOINT="https://sfo3.digitaloceanspaces.com"
|
||||
DO_BUCKET="eagle0-assets"
|
||||
|
||||
COMMON_PATHS="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH="${COMMON_PATHS}:${PATH}"
|
||||
|
||||
if ! command -v aws >/dev/null 2>&1; then
|
||||
if command -v brew >/dev/null 2>&1; then
|
||||
brew install awscli
|
||||
else
|
||||
echo "ERROR: aws CLI is unavailable, and Homebrew is not on PATH"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -d "$SERVER_DATA" ]; then
|
||||
echo "No Addressables bundles found at $SERVER_DATA"
|
||||
echo "Skipping upload (this is expected if Addressables are bundled locally)"
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
"""Split a JVM binary's runtime classpath into first-party and third-party jar sets.
|
||||
|
||||
This exists so the Docker image can place rarely-changing third-party jars in a
|
||||
lower OCI layer and frequently-changing first-party jars in a small top layer,
|
||||
so most pushes only re-upload the small layer.
|
||||
"""
|
||||
|
||||
def _unique_name(jar):
|
||||
# short_path is unique per jar and stable across commits (it depends only on
|
||||
# the jar's own package/coordinate, not on unrelated targets), so the deps
|
||||
# layer's tar entries stay byte-identical and its blob digest stays cached.
|
||||
path = jar.short_path
|
||||
if path.startswith("../"):
|
||||
path = path[3:]
|
||||
return path.replace("+", "_").replace("/", "_").replace("~", "_")
|
||||
|
||||
def _impl(ctx):
|
||||
info = ctx.attr.binary[JavaInfo]
|
||||
|
||||
app = []
|
||||
deps = []
|
||||
seen = {}
|
||||
for jar in sorted(info.transitive_runtime_jars.to_list(), key = lambda f: f.path):
|
||||
workspace = jar.owner.workspace_name if jar.owner else ""
|
||||
bucket = "app" if workspace == "" else "deps"
|
||||
out_name = _unique_name(jar)
|
||||
|
||||
key = bucket + "/" + out_name
|
||||
if key in seen:
|
||||
fail("jar_split: duplicate output name %r for %s and %s" % (
|
||||
key,
|
||||
seen[key],
|
||||
jar.path,
|
||||
))
|
||||
seen[key] = jar.path
|
||||
|
||||
link = ctx.actions.declare_file(ctx.label.name + "/" + key)
|
||||
ctx.actions.symlink(output = link, target_file = jar)
|
||||
(app if bucket == "app" else deps).append(link)
|
||||
|
||||
return [
|
||||
DefaultInfo(files = depset(app + deps)),
|
||||
OutputGroupInfo(app = depset(app), deps = depset(deps)),
|
||||
]
|
||||
|
||||
jar_split = rule(
|
||||
implementation = _impl,
|
||||
doc = "Partitions a JVM binary's transitive runtime jars into 'app' " +
|
||||
"(first-party, empty workspace) and 'deps' (third-party) output groups.",
|
||||
attrs = {
|
||||
"binary": attr.label(
|
||||
mandatory = True,
|
||||
providers = [[JavaInfo]],
|
||||
doc = "A jvm binary/library target whose runtime classpath to split.",
|
||||
),
|
||||
},
|
||||
)
|
||||
@@ -32,13 +32,6 @@ services:
|
||||
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
|
||||
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
|
||||
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
|
||||
EAGLE_HISTORY_BACKEND: "${EAGLE_HISTORY_BACKEND:-postgres}"
|
||||
EAGLE_POSTGRES_HOST: "${EAGLE_POSTGRES_HOST:-}"
|
||||
EAGLE_POSTGRES_PORT: "${EAGLE_POSTGRES_PORT:-}"
|
||||
EAGLE_POSTGRES_DATABASE: "${EAGLE_POSTGRES_DATABASE:-}"
|
||||
EAGLE_POSTGRES_USER: "${EAGLE_POSTGRES_USER:-}"
|
||||
EAGLE_POSTGRES_PASSWORD: "${EAGLE_POSTGRES_PASSWORD:-}"
|
||||
EAGLE_POSTGRES_SSLMODE: "${EAGLE_POSTGRES_SSLMODE:-require}"
|
||||
# JWT public key for token validation (auth service handles signing)
|
||||
# Reads from /etc/eagle0/keys/public.pem via shared volume
|
||||
# Auth token for Shardok on Hetzner (required)
|
||||
@@ -88,13 +81,6 @@ services:
|
||||
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
|
||||
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
|
||||
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
|
||||
EAGLE_HISTORY_BACKEND: "${EAGLE_HISTORY_BACKEND:-postgres}"
|
||||
EAGLE_POSTGRES_HOST: "${EAGLE_POSTGRES_HOST:-}"
|
||||
EAGLE_POSTGRES_PORT: "${EAGLE_POSTGRES_PORT:-}"
|
||||
EAGLE_POSTGRES_DATABASE: "${EAGLE_POSTGRES_DATABASE:-}"
|
||||
EAGLE_POSTGRES_USER: "${EAGLE_POSTGRES_USER:-}"
|
||||
EAGLE_POSTGRES_PASSWORD: "${EAGLE_POSTGRES_PASSWORD:-}"
|
||||
EAGLE_POSTGRES_SSLMODE: "${EAGLE_POSTGRES_SSLMODE:-require}"
|
||||
# JWT public key for token validation (auth service handles signing)
|
||||
# Reads from /etc/eagle0/keys/public.pem via shared volume
|
||||
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
|
||||
|
||||
@@ -68,7 +68,7 @@ private GameObject GetEffectPrefab(string beastName) {
|
||||
|
||||
### 4. Wire up in the scene
|
||||
|
||||
In `Assets/Scenes/Eagle.unity`, find the `ProvinceBeastsController` component on the Eagle map object and assign your new prefab to the new field.
|
||||
In `Gameplay.unity`, find the `ProvinceBeastsController` component and assign your new prefab to the new field.
|
||||
|
||||
### 5. Test
|
||||
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
# Postgres History Migration Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Evaluate and, if the measurements support it, migrate production game history persistence from local SQLite plus S3 base
|
||||
and delta files to a managed Postgres database.
|
||||
|
||||
This is not just a mechanical port. The important product requirement is that battle commands, especially Shardok map
|
||||
clicks, show results to the player as quickly as possible while still keeping the chance of lost results acceptably low.
|
||||
|
||||
## Proposed Direction
|
||||
|
||||
Use Postgres as the authoritative production store. Local SQLite should remain useful for development, tests, and rollback
|
||||
during rollout, but production should not have two long-lived authoritative stores for the same game.
|
||||
|
||||
Start with a small DigitalOcean Managed Postgres instance in the same region and VPC as Eagle. The $15 single-node plan is
|
||||
reasonable for early measurement and staging. If the design works, production can move to a more reliable plan when the
|
||||
availability requirements justify it.
|
||||
|
||||
## Phase 1: Measure First
|
||||
|
||||
Before porting the full history implementation, add a benchmark or admin utility that runs from the Eagle deployment
|
||||
environment against the candidate Postgres database.
|
||||
|
||||
Measure at least:
|
||||
|
||||
- single action-result append transaction latency
|
||||
- batch append latency for 25 and 100 action results
|
||||
- latest snapshot lookup
|
||||
- replay from a snapshot to a target index
|
||||
- `since` and stream-update query latency
|
||||
- Shardok battle result append latency
|
||||
- p50, p95, p99, and max latency
|
||||
- async queue lag, if an async persistence prototype is included
|
||||
|
||||
Useful initial exit criteria:
|
||||
|
||||
- Strategic action-result writes should be comfortably below 50 ms p95.
|
||||
- Shardok synchronous persistence should be below roughly 20-30 ms p95 to stay on the click-to-animation path.
|
||||
- If Shardok sync writes are above that, use an async Shardok persistence design rather than blocking player feedback.
|
||||
|
||||
## Phase 2: Schema Prototype
|
||||
|
||||
Add Postgres configuration and a prototype schema that maps the current SQLite history model.
|
||||
|
||||
Expected tables:
|
||||
|
||||
- `games`, for game-level metadata and backend bookkeeping
|
||||
- `action_results`, for ordered strategic action results
|
||||
- per-entity decomposition tables currently written by the SQLite dispatchers
|
||||
- `state_snapshots`, for replay anchors
|
||||
- Shardok battle result tables, including battle id, sequence number, command/result payloads, and player metadata
|
||||
- `schema_migrations`, unless the migration mechanism is handled outside the app
|
||||
|
||||
Prefer batched inserts and batched reads. The current SQLite implementation can afford some local-file patterns that will
|
||||
be too chatty over a network.
|
||||
|
||||
## Phase 3: Implement Postgres History
|
||||
|
||||
Implement a `FullGameHistory` backend backed by Postgres. Keep the existing SQLite backend available behind a config flag
|
||||
during rollout.
|
||||
|
||||
Core methods to support:
|
||||
|
||||
- `count`
|
||||
- `last`
|
||||
- `withNewResults`
|
||||
- `stateAfter`
|
||||
- `since`
|
||||
- `sinceDate`
|
||||
- round-local recent-result queries
|
||||
- Shardok result writes and reads
|
||||
|
||||
The implementation should preserve action ordering and idempotency. If retries are possible, writes should include stable
|
||||
keys or sequence ranges so replaying a write request cannot duplicate results.
|
||||
|
||||
## Phase 4: Decide Shardok Durability Mode
|
||||
|
||||
Shardok latency is the place where this migration can most visibly hurt the player. There are two viable modes.
|
||||
|
||||
### Option A: Synchronous Shardok Writes
|
||||
|
||||
Use this if measured Postgres latency is low enough. The command path writes results to Postgres before telling the client
|
||||
about them.
|
||||
|
||||
This is the simplest durability model, but only acceptable if it keeps click-to-animation latency low.
|
||||
|
||||
### Option B: Async Shardok Writes
|
||||
|
||||
Use this if synchronous Postgres writes add noticeable latency.
|
||||
|
||||
The model:
|
||||
|
||||
- apply battle results to authoritative in-memory game state immediately
|
||||
- send client updates immediately after the ordered in-memory apply
|
||||
- enqueue ordered persistence batches in the same order they were applied
|
||||
- persist batches with `battle_id`, sequence range, payload hash, and payload
|
||||
- track `lastAppliedSeq` and `lastPersistedSeq`
|
||||
- retry failed writes until they succeed
|
||||
- alert when persistence lag exceeds a small threshold
|
||||
- block or degrade new commands if the queue grows too large
|
||||
- force a synchronous checkpoint before battle end reconciliation or strategic-layer handoff
|
||||
- drain the queue during graceful shutdown
|
||||
|
||||
This creates a small crash-loss window, but only for rare crashes that happen after the player sees a result and before
|
||||
the async write completes. That tradeoff may be better than adding network persistence latency to every tactical command.
|
||||
|
||||
## Phase 5: Rollout
|
||||
|
||||
Add a backend flag such as:
|
||||
|
||||
```text
|
||||
EAGLE_HISTORY_BACKEND=sqlite|postgres
|
||||
```
|
||||
|
||||
Suggested rollout:
|
||||
|
||||
1. Run benchmarks from the deployed environment.
|
||||
2. Enable Postgres in staging for new games.
|
||||
3. Run warmup games and Shardok battle smoke tests.
|
||||
4. Enable Postgres for new production games.
|
||||
5. Migrate old games either lazily on first open or through an admin-triggered migration job.
|
||||
6. Keep SQLite/S3 rollback available until enough new-game production time has passed.
|
||||
|
||||
If Postgres is unavailable, commands should not be acknowledged as durable. Either hold/retry them before applying, or
|
||||
reject them clearly. Do not show a result to the client while pretending it has already been durably stored unless that is
|
||||
an explicit async-durability path with lag monitoring.
|
||||
|
||||
## Phase 6: Cleanup
|
||||
|
||||
After production confidence:
|
||||
|
||||
- remove production dependence on S3 base/delta history files
|
||||
- keep object storage for backups, exports, or emergency archives
|
||||
- keep SQLite for local development and tests if it remains useful
|
||||
- document restore, migration, and rollback procedures
|
||||
|
||||
## Open Questions
|
||||
|
||||
- What exact durability guarantee do we want for "the client already saw this result"?
|
||||
- What Shardok persistence latency is acceptable in real play?
|
||||
- Should old games migrate lazily or through a bulk/admin process?
|
||||
- When should the production database move from single-node to HA?
|
||||
- What should the player experience be if Postgres is reachable but slow?
|
||||
|
||||
## Recommended Next PR
|
||||
|
||||
The next implementation PR should add a Postgres benchmark/admin utility and configuration plumbing, not the full port.
|
||||
That gives us real latency numbers from the Eagle deployment environment before committing to the larger migration.
|
||||
@@ -136,7 +136,7 @@ Create a tool to programmatically regenerate the Eagle strategic map images with
|
||||
| `Assets/Eagle/Materials/*.png` | Replaced province masks |
|
||||
| `Assets/Eagle/map_bw_labels.png` | Either: regenerated, or removed if using dynamic labels |
|
||||
| `province_map.tsv` | Add centroid columns |
|
||||
| `Assets/Scenes/Eagle.unity` | Add ProvinceLabelsController, label prefab instances |
|
||||
| `Assets/Gameplay.unity` | Add ProvinceLabelsController, label prefab instances |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
# Shardok AI Experiment Tooling Plan
|
||||
|
||||
## Summary
|
||||
|
||||
Build a reusable workflow for Shardok AI battle baselines and scoring experiments. The goal is to
|
||||
turn the current one-off benchmark process into repeatable commands that extract real battles, run
|
||||
baseline and candidate configurations, capture detailed per-command traces, and write comparable
|
||||
reports.
|
||||
|
||||
This work starts with the target-aware archery scoring experiment: attacker `EXPERIMENTAL` against
|
||||
defender `STANDARD`.
|
||||
|
||||
## Key Changes
|
||||
|
||||
- Add simulator trace support to `ai_battle_simulator_main`.
|
||||
- New flag: `--trace-jsonl=<path>`.
|
||||
- Emit one JSONL row per AI-selected command.
|
||||
- Include run label, config path, state file, phase, sequence, round, player, side, command type,
|
||||
actor unit, target coords, chosen index, available command count, search depth, commands
|
||||
evaluated, completion reason, forced commands posted afterward, and pre/post side troop totals.
|
||||
- Keep existing summary output unchanged.
|
||||
|
||||
- Add reusable benchmark tooling under
|
||||
`src/main/cpp/net/eagle0/shardok/ai_battle_simulator/benchmarks/`.
|
||||
- `real_battle_suite.py`: shared helpers for reading `game.db`, materializing battle/state/result
|
||||
blobs, extracting configs, running simulator binaries, parsing summaries, and collecting
|
||||
real-game metadata.
|
||||
- `run_real_battle_experiment.py`: CLI orchestration for baseline/candidate runs.
|
||||
- Outputs: `summary.csv`, `comparison.csv`, `report.md`, generated configs, and per-run trace
|
||||
JSONL files.
|
||||
|
||||
- Add a clean EXPERIMENTAL switch for target-aware archery value.
|
||||
- Use a new experiment id.
|
||||
- Change only archery-availability scoring.
|
||||
- Replace the flat archery-possible value with a target-sensitive value based on the best enemy
|
||||
unit value.
|
||||
- Do not include the previous fear-value change from experiment `27`.
|
||||
|
||||
## Execution Plan
|
||||
|
||||
- Create a feature branch from fetched `origin/main`.
|
||||
- Commit this plan document first.
|
||||
- Implement simulator trace output and reusable benchmark tooling.
|
||||
- Add the target-aware archery-only EXPERIMENTAL switch.
|
||||
- Run the new tool first on battle `6248`, then on the all-40 saved-battle suite if local runtime is
|
||||
acceptable.
|
||||
- Compare baseline attacker `STANDARD` / defender `STANDARD` with candidate attacker
|
||||
`EXPERIMENTAL` / defender `STANDARD`.
|
||||
- Record the results under the existing benchmark results area and call out battle `6248` in the
|
||||
generated report.
|
||||
- Push the branch and create a PR after local validation.
|
||||
|
||||
## Tests
|
||||
|
||||
- Build:
|
||||
- `bazel build //src/main/cpp/net/eagle0/shardok/ai_battle_simulator:ai_battle_simulator_main`
|
||||
- `bazel build //src/main/cpp/net/eagle0/shardok/ai_battle_simulator:real_battle_config_extractor`
|
||||
- `bazel build //src/main/cpp/net/eagle0/shardok/ai_battle_simulator:real_battle_smoke_metadata`
|
||||
- Unit/integration:
|
||||
- `bazel test //src/test/cpp/net/eagle0/shardok/ai_battle_simulator:ai_battle_simulator_test`
|
||||
- Add or extend a simulator test that verifies trace output is created and contains required
|
||||
command fields.
|
||||
- `python3 -m py_compile` on the new benchmark scripts.
|
||||
- Validation:
|
||||
- Run the new tool on battle `6248`.
|
||||
- Run the new tool on all 40 battles if feasible locally.
|
||||
- Run `bazel test //src/test/cpp/...` before push if local time allows; otherwise open the PR
|
||||
promptly and continue validation while CI starts.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- "Experiment 3" means target-aware archery value from the previous recommendation list.
|
||||
- The first experiment compares attacker-only candidate behavior against unchanged defender
|
||||
`STANDARD`.
|
||||
- JSONL is the trace format because command diagnostics will evolve over time.
|
||||
- Python benchmark scripts follow the existing executable-script pattern rather than adding Python
|
||||
Bazel targets.
|
||||
@@ -1,82 +0,0 @@
|
||||
# Shardok AI Scoring Benchmarks
|
||||
|
||||
The current STANDARD scoring experiment results are recorded in
|
||||
`src/main/cpp/net/eagle0/shardok/ai_battle_simulator/benchmarks/experimental_results/`.
|
||||
They use 40 saved Shardok battles extracted from `game_389ffb1901903118.zip`.
|
||||
|
||||
The corrected all-40 run uses:
|
||||
|
||||
- `max_rounds = 31`
|
||||
- `random_seed = 1`
|
||||
- iterative-deepening timeout propagation fix from PR #6923
|
||||
- AI setup placement
|
||||
- forced single-option commands
|
||||
- defender scoring fixed to `STANDARD` for every row
|
||||
- archery/start-fire capability derived like production setup
|
||||
- per-unit flee capability preserved by real-battle extraction
|
||||
- zero-troop combat heroes preserved as live units
|
||||
- `NO_PROFESSION` treated as a valid hero profession state, not as "no hero"
|
||||
|
||||
Shardok siege battles do not have a draw result. If the attacker has not won after 31 rounds, the
|
||||
defender wins. The simulator records those cutoffs as `max_rounds_reached` defender wins.
|
||||
|
||||
All 40 saved battle payloads now produce valid simulator configs. A battle side can have zero
|
||||
remaining troops as long as it has a hero; this is common for `NO_PROFESSION` heroes with no
|
||||
commanded battalion.
|
||||
|
||||
An all-40 real-vs-sim comparison records whether the saved-game attacker/defender were human or AI
|
||||
players. Factions `3` and `4` are human players; all other factions in this save are AI players.
|
||||
The comparison uses broad winner side from the saved Shardok result payload, because exact replay
|
||||
is not available from the compact result rows. Real end-of-battle troop totals come from the
|
||||
all-factions Shardok `ActionResultView` stream's final `changed_player_totals`.
|
||||
|
||||
An AI-vs-AI smoke check also reruns a subset where neither side used faction `3` or `4`.
|
||||
|
||||
## Current Results
|
||||
|
||||
These artifacts are the current baseline for future scoring comparisons.
|
||||
|
||||
| Dataset | Valid battles | Invalid rows | Attacker wins | Defender wins | Notes |
|
||||
| --- | ---: | ---: | ---: | ---: | --- |
|
||||
| All-40 timeout-fix STANDARD baseline | 40 | 0 | 18 | 22 | `standard_scoring_all40_post_bugfix_baseline.*`; regenerated after fixing iterative-deepening timeout score propagation |
|
||||
| All-40 real saved game | 40 known | 0 unknown | 19 | 21 | `standard_scoring_all40_real_vs_sim.*`; sim matches 39/40 known winners |
|
||||
| AI-vs-AI smoke | 31 | 0 | 10 | 21 | 31/31 winner-side matches |
|
||||
|
||||
The remaining real-vs-sim winner-side miss is `11754` (real attacker, sim defender), which had an
|
||||
AI attacker but a human defender in the saved game. The real attacker won with `2119` troops
|
||||
remaining; the STANDARD-vs-STANDARD simulation replaces the human defender with AI and the defender
|
||||
wins with `1000` troops remaining, so this row is not a comparable AI-vs-AI smoke miss. Battle
|
||||
`6248`, previously the remaining AI-vs-AI miss, now matches as an attacker win after expected-impact
|
||||
archery scoring was promoted to STANDARD.
|
||||
|
||||
The closest six valid baseline rows are used for the scalar and behavior experiment batches. The
|
||||
single-run harness still has some nondeterminism even with `random_seed = 1`, so near-margin changes
|
||||
should be repeated before being promoted.
|
||||
|
||||
## Experiment Tooling
|
||||
|
||||
`src/main/cpp/net/eagle0/shardok/ai_battle_simulator/benchmarks/run_real_battle_experiment.py`
|
||||
is the reusable real-battle experiment runner. It can read a saved-game zip or extracted `game.db`,
|
||||
generate baseline/candidate simulator configs, run both sides, and write `summary.csv`,
|
||||
`comparison.csv`, `report.md`, generated configs, and per-command trace JSONL files.
|
||||
|
||||
The repository keeps the current STANDARD baseline artifacts, but omits generated configs/traces
|
||||
for abandoned experiment batches. Regenerate those locally with the runner when a new comparison
|
||||
needs command-level traces.
|
||||
|
||||
`src/main/cpp/net/eagle0/shardok/ai_battle_simulator/benchmarks/run_standard_baselines.py`
|
||||
regenerates the tracked STANDARD-vs-STANDARD baseline artifacts from a saved-game zip or extracted
|
||||
`game.db`, including the all-40 baseline, real-vs-sim comparison, and AI-vs-AI smoke files.
|
||||
|
||||
The simulator supports `--trace-jsonl=<path>` and `--run-label=<label>` for detailed command traces.
|
||||
Each row records the selected command, search depth, forced command count, and pre/post side troop
|
||||
totals.
|
||||
|
||||
Expected-impact archery replaced the old flat archery-position value in STANDARD. A unit that can
|
||||
shoot now gets half of the expected volley impact; a unit that is merely positioned for a future
|
||||
volley gets one quarter. Promoting that behavior flips `6248` to match the real AI-vs-AI attacker
|
||||
win without introducing an AI-vs-AI winner-side miss in the current saved-battle baseline.
|
||||
|
||||
The latest baseline refresh was generated after the iterative-deepening evaluator stopped letting
|
||||
timeout fallback scores compete with completed command evaluations. Future STANDARD-vs-EXPERIMENTAL
|
||||
comparisons should compare against these checked-in timeout-fix baseline artifacts.
|
||||
@@ -6,16 +6,6 @@ Be able to support a small (10-50 user) private alpha, including with strangers.
|
||||
|
||||
Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/17RTt3-4Wl2AAVMRLodaC3a84E4de6xuQTWBPvCRM484/edit?pli=1&tab=t.0), but most of that is not necessary for MVP.
|
||||
|
||||
## Current priority order
|
||||
|
||||
1. Finish tutorial & first-session onboarding, especially the clear first-session goal and guided first scenario.
|
||||
2. Fix the basic Shardok AI issues that can make tactical battles feel broken or unfair.
|
||||
3. Publish a known issues doc so alpha testers do not repeatedly report the same rough edges.
|
||||
4. Verify the existing game-ended flow, then mark the win condition done if "all other factions defeated" works end-to-end.
|
||||
5. Add mid-game progression events where the King recognizes the player as they gain power.
|
||||
6. Add account-linking when provider switching or account recovery becomes important for alpha support.
|
||||
7. Leave terms of service and Windows code signing deferred until they matter for public release or broader distribution.
|
||||
|
||||
## Required
|
||||
|
||||
### Gameplay Productionization
|
||||
@@ -27,7 +17,7 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
|
||||
- [x] ~~Fix long disconnects on deployments~~
|
||||
- [x] Fix the Mac installer
|
||||
- [x] ~~Still not reconnecting after deployments~~
|
||||
- [x] Notify about client updates, button to come directly back
|
||||
- [ ] Notify about client updates, button to come directly back
|
||||
- [x] Generatedtext healing
|
||||
- [x] Kill outstanding shardok requests when game is deleted
|
||||
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
# SQLite Action Result Decomposition
|
||||
|
||||
Follow-up to [SQLITE_HISTORY_DESIGN.md](SQLITE_HISTORY_DESIGN.md). This document plans the second wave of the SQLite migration: decomposing the `ActionResult` proto blob in `action_results.payload` into native relational tables, deleting the proto representation entirely for everything in scope.
|
||||
|
||||
## Status — option B adopted (updated 2026-05-17)
|
||||
|
||||
**This section supersedes the original plan below. Where the body conflicts with this section, this section wins.** The full-relational design is kept below for historical context and as the migration target if a structured-query need on deep aggregates ever materializes.
|
||||
|
||||
The original plan (decompose every field into relational tables; "decision locked") was implemented for `ChangedProvince` first — ~37 child tables, ~1500 lines (PR #6721) — and then **rejected on cost/benefit**. We adopted **option B**: apply the "stopping rule" (originally carved out only for `Quest`) *consistently*. Shipped and merged as Phase 4.5b in **PR #6725**:
|
||||
|
||||
- Keep the per-entity **top-level table with scalar / entity-reference columns + indexes** (the 4.5a tables). That is the part that earns its keep — "which actions changed province/hero/faction X, by how much" is one-line SQL.
|
||||
- Store the **rest of the aggregate as one `<entity>_proto BLOB` column** on that row: lossless round-trip / replay, no child tables for the deep nesting.
|
||||
- Scalar columns are pure query denormalization; **the proto blob is authoritative**. A later fully-relational pass (if ever needed) is a pure read-blob → write-columns migration — which is why the blob must survive any future proto deletion.
|
||||
|
||||
Rationale: the deep army/unit/hero/claimant nesting is never analytics-queried — the exact property that justified blobbing `Quest`. Exploding it into ~37 tables was code volume without query value. The blob also means deep-aggregate field changes need **no SQL migration** (only promoted scalar columns do), which is the right shape for the volatile parts of the model. This rescinds "Decisions locked #1" and revises principle 7, the Scope, the Sequencing table, and "Deleting the proto" (all updated in place below).
|
||||
|
||||
## Refinement — "refined option B" for shallow entities (updated 2026-05-19)
|
||||
|
||||
**This section further supersedes the body and the Status section above where they conflict; it wins.** It records the pattern actually shipped for `ChangedHero` in Phase 4.5c.1 (#6727), which is a refinement of option B, not the whole-entity-blob shape the body still describes for that entity.
|
||||
|
||||
Option B as originally stated keeps **one `<entity>_proto BLOB`** per row for the entire non-scalar residual. For a *shallow* entity that is more blob than it needs. Refined option B decomposes the residual into three tiers instead of one blob:
|
||||
|
||||
- **Tier 1 — scalar / entity-reference columns + indexes.** The queryable surface (deltas, touched entities, enum ordinals). Always present; unchanged from 4.5a.
|
||||
- **Tier 2 — typed columns for sealed value-oneofs.** A sealed choice over simple values (e.g. `ChangedHeroC`'s `loyalty` / `vigor` `StatChange`, subtypes `StatDelta`/`StatAbsolute`/`StatNoChange`) becomes a **pair of nullable columns** (`loyalty_delta` / `loyalty_absolute`, …). The *column name is the discriminator*; at most one of each pair is non-NULL; both NULL ⇒ the no-change case. No `kind` column.
|
||||
- **Tier 3 — per-row leaf-proto blobs in a child table.** A repeated/optional nested aggregate becomes a child table keyed by `(action_seq, entity_id, n)` storing the **leaf** proto's bytes one row per element (e.g. `action_changed_heroes_new_backstory_events` holding `EventForHeroBackstory` blobs) — *not* a whole-entity blob.
|
||||
|
||||
**Consequence (changes Decisions #1–#3 and 4.5i):** an entity fully covered by Tiers 1–3 has **no whole-entity blob**. It round-trips losslessly from columns + per-leaf blobs, so its per-entity proto and converter (`changed_hero.proto` / `ChangedHeroConverter`) become genuinely dead code, deletable in 4.5i. Only the **leaf** protos used as Tier-3 blob payloads (`event_for_hero_backstory.proto`) are retained as a wire format. The whole-entity `<entity>_proto BLOB` is now reserved for **deep** entities where Tiers 2–3 would be more work than value (`ChangedProvince`, the "new entity" snapshots).
|
||||
|
||||
Per-entity decision (supersedes Decisions #2): classify each entity as **all-scalar** (Tier 1 only, no blob — e.g. likely `ChangedFaction`), **refined** (Tiers 1–3, no whole-entity blob, proto deletable — `ChangedHero`), or **deep** (Tier 1 + whole-entity `<entity>_proto BLOB`, proto retained — `ChangedProvince`, new-entity snapshots). Default to **refined** for shallow entities; fall back to **deep** only when the residual is too varied to be worth Tiers 2–3. Wherever the body or Status says "the proto blob is authoritative," for a *refined* entity read "the columns + per-leaf-proto blobs are authoritative."
|
||||
|
||||
## Why
|
||||
|
||||
The first wave (Phases 1–4) stores each action as a single proto blob in `action_results.payload`. That blob is opaque to SQL — any query about action contents (changed provinces, heroes affected, etc.) requires deserializing every row. The `action_result_type`, `round_id`, and date columns we denormalized are the only handles SQL has into actions.
|
||||
|
||||
Two observations make full decomposition more attractive than the original design admitted:
|
||||
|
||||
1. **The proto exists for storage only.** 92 Scala files reference `ChangedProvince`, but exactly one imports the proto type — `ChangedProvinceConverter.scala`. Everything else (action handlers, AI, utils) operates on `ChangedProvinceC`. The proto is **not** in any client-facing RPC contract — it lives under `eagle.internal`. Same is true for `ChangedHero`, `ChangedFaction`, `ChangedBattalion`, `Notification`, `GeneratedTextRequest`, and `ActionResult` itself. Replacing proto serialization with SQL writes does **not** lose forward/backward compatibility, because there are no external consumers depending on the proto wire format.
|
||||
|
||||
2. **`ActionResultC` has no polymorphism at the top level.** It's a 34-field flat struct — `Option`s and `Vector`s, but no `sealed trait` discriminator at the action level. Every action has the same shape; the `actionResultType` enum tells you *which fields* are populated. This means the entire structure is relationalizable without per-action-type tables.
|
||||
|
||||
The work is bounded, the proto becomes dead code at the end of it, and it has to happen before alpha — once we have users, every schema change costs a real migration. Doing it pre-alpha lets us nuke save dirs as part of deployment.
|
||||
|
||||
## Scope
|
||||
|
||||
**In:** `action_results.payload` (the whole-`ActionResult` blob) is gone by the end. Per-entity scalar / entity-reference columns make the *queryable* surface (deltas, touched entities) native SQL. **Under refined option B (see Status):** `internal/action_result.proto` and *deep* entities' sub-message protos (`changed_province.proto`, …) are retained as blob payloads; *refined* entities' protos (`changed_hero.proto`) are decomposed away and deleted, with only their Tier-3 leaf protos (`event_for_hero_backstory.proto`) kept. (The original plan deleted all of them, then a draft of option B kept all of them; neither holds — it's now per-entity.)
|
||||
|
||||
**Out:**
|
||||
- `state_snapshots.payload` — stays as proto-blob `GameState`. These are caches for replay, not authoritative data; querying inside a snapshot adds nothing over querying the action deltas that produced it. Cost/benefit doesn't justify decomposing them.
|
||||
- Shardok tables — already designed in Phase 3, no changes here. The shardok protos (`ShardokActionResult`, etc.) are owned by the Shardok subsystem and are part of an actual RPC contract; they stay.
|
||||
- Client text — already SQLite-backed (`SqliteClientTextStore`).
|
||||
|
||||
## Principles
|
||||
|
||||
1. **One table per non-trivial aggregate type.** Every `Vector[X]` or `Option[X]` where `X` is a Scala case class with multiple fields gets its own table.
|
||||
2. **Vectors of primitives → join tables.** `Vector[Int]` becomes a two-column table: `(parent_key, value)`. No JSON arrays, no comma-separated strings — those defeat the point.
|
||||
3. **Optional aggregates → presence by row existence.** `Option[X]` for aggregate `X` is a row that may or may not exist with the matching parent key. The natural primary key on `(action_seq)` enforces 0-or-1-row semantics for `Option[X]` aggregates and `(action_seq, n)` for vectors.
|
||||
4. **Sealed traits get a discriminator column.** When a Scala sealed trait has a few subtypes with diverging fields, prefer one wide table with `kind TEXT NOT NULL` + nullable per-subtype columns over per-subtype tables. When subtypes diverge significantly (>5 unrelated fields each), use per-subtype tables. Decide per case.
|
||||
5. **`ON DELETE CASCADE` everywhere.** Every child table has `FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE`. `truncateTo` becomes a single `DELETE FROM action_results WHERE action_seq >= ?` and the cascades do the rest.
|
||||
6. **Schema migrations are now real.** Once we have a relational schema, evolving it means SQL migrations. We introduce a real migration runner (see "Schema evolution" below). The `metadata` table's `schema_version` becomes load-bearing.
|
||||
7. **(Revised under option B — see Status.) The proto blob is the retained authoritative store for each entity's deep aggregates; scalar / entity-reference columns are denormalized projections of it.** The original principle assumed full decomposition and proto deletion. Instead each entity keeps one proto-blob source of truth and denormalizes only the queryable columns from the same write — so there is no dual *relational* representation to keep in sync.
|
||||
|
||||
## Top-level: `action_results`
|
||||
|
||||
The `payload BLOB` column goes away. Scalar fields become columns; aggregate fields move to related tables.
|
||||
|
||||
```sql
|
||||
CREATE TABLE action_results (
|
||||
action_seq INTEGER PRIMARY KEY,
|
||||
action_result_type INTEGER NOT NULL,
|
||||
-- existing scalar denormalizations
|
||||
round_id INTEGER NOT NULL,
|
||||
date_year INTEGER,
|
||||
date_month INTEGER,
|
||||
-- new scalar columns (formerly inside payload)
|
||||
acting_hero_id INTEGER,
|
||||
acting_faction_id INTEGER,
|
||||
province_id INTEGER,
|
||||
province_id_acted INTEGER,
|
||||
new_round_phase INTEGER,
|
||||
new_round_id INTEGER,
|
||||
last_command_type_for_acting_province INTEGER,
|
||||
resolved_battle TEXT,
|
||||
new_victor_faction_id INTEGER,
|
||||
game_ended INTEGER, -- 0/1/NULL
|
||||
new_random_seed INTEGER,
|
||||
new_game_type INTEGER
|
||||
);
|
||||
|
||||
-- Vectors of bare IDs become join tables:
|
||||
CREATE TABLE action_destroyed_battalion_ids (
|
||||
action_seq INTEGER NOT NULL,
|
||||
battalion_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (action_seq, battalion_id),
|
||||
FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE action_removed_hero_ids (
|
||||
action_seq INTEGER NOT NULL,
|
||||
hero_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (action_seq, hero_id),
|
||||
FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE action_removed_faction_ids (...);
|
||||
CREATE TABLE action_affected_faction_ids (...);
|
||||
|
||||
-- Singleton optional aggregates: a row exists if and only if Option is Some.
|
||||
CREATE TABLE action_new_battles (
|
||||
action_seq INTEGER PRIMARY KEY,
|
||||
-- ShardokBattle fields here, columns or sub-tables as needed
|
||||
FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE action_new_chronicle_entries (action_seq INTEGER PRIMARY KEY, ...);
|
||||
CREATE TABLE action_new_eagle_map_info (action_seq INTEGER PRIMARY KEY, ...);
|
||||
```
|
||||
|
||||
Each `Vector[Aggregate]` field on `ActionResultC` gets its own table (covered below).
|
||||
|
||||
### Indexes
|
||||
|
||||
We keep the existing indexes (`round_id`, `(date_year, date_month)`) and add a few that obvious queries need:
|
||||
|
||||
- `idx_action_results_acting_hero_id`
|
||||
- `idx_action_results_acting_faction_id`
|
||||
- `idx_action_results_province_id`
|
||||
|
||||
Plus indexes on the per-entity join tables on the entity-id side, so cross-game queries like "all actions touching hero X" become single-index lookups:
|
||||
|
||||
- `idx_action_changed_provinces_province_id`
|
||||
- `idx_action_changed_heroes_hero_id`
|
||||
- `idx_action_changed_faction_faction_id`
|
||||
- `idx_action_changed_battalions_battalion_id`
|
||||
|
||||
## Changed-entity tables (four parallel structures)
|
||||
|
||||
### `action_changed_provinces`
|
||||
|
||||
`ChangedProvinceC` has ~50 fields: scalar deltas, nested aggregates, ID vectors. The scalar fields become columns on `action_changed_provinces`; aggregate fields become child tables.
|
||||
|
||||
```sql
|
||||
CREATE TABLE action_changed_provinces (
|
||||
action_seq INTEGER NOT NULL,
|
||||
province_id INTEGER NOT NULL,
|
||||
-- resource changes
|
||||
gold_delta INTEGER,
|
||||
food_delta INTEGER,
|
||||
-- stat changes
|
||||
new_price_index REAL,
|
||||
economy_delta REAL,
|
||||
agriculture_delta REAL,
|
||||
infrastructure_delta REAL,
|
||||
economy_devastation_delta REAL,
|
||||
agriculture_devastation_delta REAL,
|
||||
infrastructure_devastation_delta REAL,
|
||||
support_delta REAL,
|
||||
-- round properties
|
||||
set_has_acted INTEGER, -- 0/1/NULL
|
||||
set_ruler_is_traveling INTEGER,
|
||||
-- ruling faction changes
|
||||
clear_ruling_faction_id INTEGER NOT NULL DEFAULT 0,
|
||||
new_ruling_faction_id INTEGER,
|
||||
-- misc scalar fields
|
||||
new_locked_improvement_kind TEXT, -- 'none', 'new'
|
||||
new_locked_improvement_value INTEGER, -- ImprovementType if kind='new'
|
||||
new_province_orders INTEGER, -- ProvinceOrderType enum
|
||||
clear_defending_army INTEGER NOT NULL DEFAULT 0,
|
||||
cleared_pending_conquest_info INTEGER NOT NULL DEFAULT 0,
|
||||
removed_deferred_change_index INTEGER,
|
||||
PRIMARY KEY (action_seq, province_id),
|
||||
FOREIGN KEY (action_seq) REFERENCES action_results(action_seq) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX idx_action_changed_provinces_province_id
|
||||
ON action_changed_provinces(province_id);
|
||||
```
|
||||
|
||||
The vector and optional-aggregate fields of `ChangedProvinceC` become per-aggregate child tables, all keyed by `(action_seq, province_id)`:
|
||||
|
||||
- `action_changed_provinces_new_unaffiliated_heroes(action_seq, province_id, n, ...)` — one row per added hero, with `UnaffiliatedHero` fields as columns
|
||||
- `action_changed_provinces_changed_unaffiliated_heroes(...)` — same shape
|
||||
- `action_changed_provinces_removed_unaffiliated_hero_ids(action_seq, province_id, hero_id)` — join table for ID vector
|
||||
- `action_changed_provinces_new_captured_heroes(...)` — `CapturedHero` fields
|
||||
- `action_changed_provinces_recruitment_attempted_captured_hero_ids(...)` — join table
|
||||
- `action_changed_provinces_removed_captured_hero_ids(...)` — join table
|
||||
- `action_changed_provinces_new_ruling_faction_hero_ids(...)` — join table
|
||||
- `action_changed_provinces_removed_ruling_faction_hero_ids(...)` — join table
|
||||
- `action_changed_provinces_new_battalion_ids(...)` — join table
|
||||
- `action_changed_provinces_removed_battalion_ids(...)` — join table
|
||||
- `action_changed_provinces_new_incoming_armies(...)` — `MovingArmy` decomposition
|
||||
- `action_changed_provinces_removed_incoming_army_ids(...)` — join table
|
||||
- `action_changed_provinces_new_withdrawing_armies(...)` — `MovingArmy` decomposition
|
||||
- `action_changed_provinces_removed_withdrawing_army_ids(...)` — join table
|
||||
- `action_changed_provinces_new_hostile_armies(...)` — `HostileArmyGroup` decomposition
|
||||
- `action_changed_provinces_removed_hostile_army_faction_ids(...)` — join table
|
||||
- `action_changed_provinces_hostile_army_status_changes(action_seq, province_id, faction_id, new_status)` — small flat aggregate
|
||||
- `action_changed_provinces_new_defending_army` — `Option[Army]` (singleton row if present)
|
||||
- `action_changed_provinces_new_incoming_shipments(...)` — `MovingSupplies` decomposition
|
||||
- `action_changed_provinces_removed_incoming_shipment_ids(...)` — join table
|
||||
- `action_changed_provinces_new_incoming_end_turn_actions(...)` — `IncomingEndTurnAction` decomposition
|
||||
- `action_changed_provinces_removed_incoming_end_turn_actions(...)` — same shape (these are values, not IDs)
|
||||
- `action_changed_provinces_new_battle_revelations(...)` — `BattleRevelation` decomposition
|
||||
- `action_changed_provinces_removed_battle_revelations(...)` — same shape
|
||||
- `action_changed_provinces_new_pending_conquest_info` — `Option[PendingConquestInfo]` (singleton row)
|
||||
- `action_changed_provinces_new_province_events` — `Vector[ProvinceEvent]`
|
||||
- `action_changed_provinces_new_deferred_change` — `Option[DeferredChange]` (singleton, with discriminator since `DeferredChangeT` is sealed)
|
||||
|
||||
That's a lot of tables — ~25 just under `ChangedProvince`. The schema is mechanical once the principles are set; what makes it tractable is that every table follows the same shape: parent FK, `n` (vector index) where ordering matters, the aggregate's fields as columns, child aggregates as their own tables.
|
||||
|
||||
### `action_changed_heroes`, `action_changed_factions`, `action_changed_battalions`
|
||||
|
||||
Same pattern. Each gets its top-level table keyed by `(action_seq, entity_id)`, plus child tables for nested aggregates and ID vectors. Indexes on the entity id (`hero_id`, `faction_id`, `battalion_id`) so cross-game queries are fast.
|
||||
|
||||
`ChangedHeroC` is a *refined* entity (see "Refinement" in Status): no whole-entity blob. Its sealed `StatChange` (`StatDelta`/`StatAbsolute`/`StatNoChange`) `loyalty` and `vigor` oneofs are Tier-2 column pairs (`loyalty_delta`/`loyalty_absolute`, `vigor_delta`/`vigor_absolute`; column name discriminates, both-NULL ⇒ `StatNoChange`). Its `newBackstoryEvents: Vector[EventForHeroBackstory]` is Tier-3: per-event `EventForHeroBackstory` blobs, one row each, in `action_changed_heroes_new_backstory_events`. Columns + per-event blobs round-trip losslessly, so `changed_hero.proto`/`ChangedHeroConverter` become deletable (4.5i) and only `event_for_hero_backstory.proto` is retained as a blob format.
|
||||
|
||||
## "New" entity tables
|
||||
|
||||
`ActionResultC.newProvinces: Vector[ProvinceT]`, `newHeroes`, `newFactions`, `newBattalions`. These are *full snapshots* of new entities at creation.
|
||||
|
||||
These are bigger and recursive: `ProvinceT` itself has fields including its armies, heroes, battalions, events, etc. Decomposing fully duplicates the structure already covered by the existing `state_snapshots` blob (which we're explicitly keeping). The decomposition still needs to happen because we're dropping the proto entirely — and these vectors live in `ActionResultC`, which we're decomposing.
|
||||
|
||||
**Decision**: go fully relational here too. Each "new entity" gets its top-level table (`action_new_provinces` etc.) keyed by `(action_seq, entity_id)`, with the same per-aggregate-field decomposition pattern as the changed-entity tables. Where a "new entity" type contains its own nested aggregates, those aggregates either share child tables with the `changed_*` family (preferred where the field shape is identical, e.g., `MovingArmy`, `UnaffiliatedHero`) or get their own (where they diverge).
|
||||
|
||||
This is the most expensive single chunk of the decomposition — explicitly called out so we know what we're committing to in PR 4.5f.
|
||||
|
||||
## Notifications and generated text requests
|
||||
|
||||
```sql
|
||||
CREATE TABLE action_new_notifications (action_seq INTEGER NOT NULL, n INTEGER NOT NULL, ...);
|
||||
CREATE TABLE action_removed_notifications (action_seq INTEGER NOT NULL, n INTEGER NOT NULL, ...);
|
||||
CREATE TABLE action_new_generated_text_requests (...);
|
||||
CREATE TABLE action_client_text_visibility_extensions (...);
|
||||
```
|
||||
|
||||
`NotificationT` and `GeneratedTextRequestT` are both sealed traits. Use the discriminator-column pattern: `kind TEXT NOT NULL` plus nullable per-subtype columns, unless a subtype's fields are too divergent to share a row.
|
||||
|
||||
## Schema evolution
|
||||
|
||||
We can't punt on this any more. With this many tables, the next field addition can't be "edit the proto." It needs a real migration mechanism.
|
||||
|
||||
Conventions:
|
||||
- `metadata['schema_version']` is a small integer, currently `1`.
|
||||
- A `Migrations` object holds an ordered `Vector[(Int, Connection => Unit)]` of migration steps.
|
||||
- On `SqliteHistory.loaded`, the schema version is read; any pending migrations are applied in a single transaction; the version is bumped.
|
||||
- New games start at the latest version.
|
||||
- Migration steps are append-only — never edit an existing step.
|
||||
|
||||
This is the standard Rails / Flyway / etc. pattern, scaled down to one file.
|
||||
|
||||
## Read path: reconstructing `ActionResultC`
|
||||
|
||||
The current pattern:
|
||||
```scala
|
||||
val proto = ActionResult.parseFrom(payloadBlob)
|
||||
val scala = ActionResultProtoConverter.fromProto(proto)
|
||||
```
|
||||
|
||||
The new pattern:
|
||||
```scala
|
||||
val scala = ActionResultDbReader.read(connection, actionSeq) // assembles from action_results + child tables
|
||||
```
|
||||
|
||||
`ActionResultDbReader.read` is one query per child table (or a single multi-result query with carefully ordered joins, though that has aggregation pitfalls). Per-table queries are simpler to write and SQLite query planning handles them well.
|
||||
|
||||
For bulk reads (`since`, `all`, `sinceDate`, etc.), the existing `replayFrom` helper iterates action seqs in order, and currently parses one proto per action. The new equivalent runs one batched query per child table for the relevant action_seq range, materializing rows into a map keyed by action_seq, then walks the range emitting `ActionResultC` instances assembled from the maps. This is more code than the proto version but doesn't change the algorithmic shape.
|
||||
|
||||
## Write path
|
||||
|
||||
`withNewResults` becomes:
|
||||
1. INSERT into `action_results` (scalar fields).
|
||||
2. For each non-empty aggregate: INSERT N rows into the corresponding child table.
|
||||
|
||||
All inside a single transaction. Per-action insert count is bounded by what the action does — most actions touch 0–3 provinces, 0–5 heroes, 0–10 changed entities total, plus 0–2 notifications. So the typical insert is ~5–20 rows. With WAL and a single per-batch transaction, this is cheap.
|
||||
|
||||
## Replay performance
|
||||
|
||||
Today's `replayFrom` parses one proto per action. Decomposed, each action requires per-child-table queries. For a 25-action replay between snapshots, that's still small absolute work — maybe 100–500 rows fetched from a handful of indexed tables. Should be faster than proto parsing once the JIT warms up, but won't matter either way at typical sizes.
|
||||
|
||||
## Deleting the proto
|
||||
|
||||
> **Superseded by refined option B (see Status).** Whole-`ActionResult` `payload` is removed in 4.5h. After that it depends on per-entity classification: *refined* entities' per-entity sub-message protos + converters (e.g. `changed_hero.proto`) **do** become dead code and are deleted in 4.5i; *deep* entities' whole-entity blob protos, the leaf protos used as Tier-3 blob payloads (e.g. `event_for_hero_backstory.proto`), and `Quest` are *retained*. The original full-deletion plan below is kept for historical context.
|
||||
|
||||
Once `SqliteHistory` writes and reads exclusively from the new tables:
|
||||
|
||||
1. Delete `ActionResultProtoConverter.scala`, the `Changed{Province,Hero,Faction,Battalion}Converter.scala` files, the `Notification`/`GeneratedTextRequest`/etc. converters.
|
||||
2. Delete the proto definitions: `action_result.proto`, `changed_province.proto`, `changed_hero.proto`, `changed_faction.proto`, `notification.proto`, `generated_text_request.proto`, anything else in this scope.
|
||||
3. Delete the corresponding `_scala_proto` Bazel targets.
|
||||
4. Gazelle should drop the unused deps from downstream targets.
|
||||
|
||||
This is satisfying but should land *last*, after everything's stable. The proto files are dead code at that point — keeping them around for a PR or two while we verify migration is fine.
|
||||
|
||||
## Sequencing
|
||||
|
||||
**Phase 4** (cutover) lands as planned: `GamesManager` swaps to `SqliteHistory`, save dirs get nuked, action data is the single proto-blob `payload`. Days of work, validates the lifecycle integration without conflating with schema design.
|
||||
|
||||
**Phase 4.5** (this design) starts after Phase 4 is verified stable. Subdivided into small PRs. **Re-planned for option B (see Status):** each entity gets the 4.5a scalar/entity-ref columns + a `<entity>_proto BLOB`; no child-table explosion. Each decomposition PR splits into `.1` (schema + backfill over existing `payload`) and `.2` (live `withNewResults` write path + read path), per the precedent set by 4.5b.
|
||||
|
||||
| PR | Work | State |
|
||||
|---|---|---|
|
||||
| 4.5a | Schema-migration scaffolding + four changed-entity top-level tables (scalar columns) | ✅ merged |
|
||||
| 4.5b | `ChangedProvince`: scalar columns + `changed_province_proto BLOB` + v3 backfill (option B, #6725) | ✅ merged |
|
||||
| 4.5c.1 | `ChangedHero` (**refined option B**, see Status): 4.5a scalars + loyalty/vigor Tier-2 column pairs + `newBackstoryEvents` as per-event `EventForHeroBackstory` Tier-3 blobs in `action_changed_heroes_new_backstory_events`; **no whole-entity blob** — `changed_hero.proto`/`ChangedHeroConverter` become deletable, `event_for_hero_backstory.proto` retained. Schema v4 + backfill over existing `payload` (#6727) | ✅ merged |
|
||||
| 4.5c.2 | `ChangedHero`: live `withNewResults` write path + read path | planned |
|
||||
| 4.5d | `ChangedFaction`: same pattern (mostly scalar already; small/empty blob — see Decisions #2) | planned |
|
||||
| 4.5e | `ChangedBattalion`: `changed_battalion_proto BLOB` (sole field is the contained `BattalionT`) + battalion_id index from 4.5a | planned |
|
||||
| 4.5f | "New entity" vectors (`newProvinces`/`newHeroes`/`newFactions`/`newBattalions`): one table per type keyed by `(action_seq, entity_id)` + a couple of queryable handles + `<entity>_proto BLOB`. Biggest win for B — these are the deepest snapshots | planned |
|
||||
| 4.5g | Notifications + generated text requests: `(action_seq, n, kind, <entity>_proto BLOB)` — `kind` discriminator queryable, payload blobbed (quest-hybrid shape) | planned |
|
||||
| 4.5h | Top-level `ActionResultC` scalar/entity-ref columns on `action_results` (acting_hero_id, acting_faction_id, province_id, …) + **drop `payload BLOB`**. Read path = scalar columns + per-entity proto blobs (close to today's proto parse; simpler than the original per-table-join plan) | planned |
|
||||
| 4.5i | Delete proto files/targets that became dead code. *Refined* entities' per-entity protos + converters (e.g. `changed_hero.proto`/`ChangedHeroConverter`) **are deleted** here. *Deep* entities' protos + leaf Tier-3 protos (e.g. `event_for_hero_backstory.proto`) + `Quest` are **retained** as blob payloads. No longer near-nothing — scope scales with how many entities went *refined* | planned |
|
||||
|
||||
Each PR nukes save dirs as part of its rollout (still pre-alpha). Each PR is independently reviewable and revertable. Under option B the per-PR cost is far lower than the original estimate (~110-line writer per entity, no child-table schema), so the remaining sequence is roughly **1 week**, not 3–4.
|
||||
|
||||
After 4.5: Phase 5 (idle-game eviction) and Phase 6 (final cleanup) proceed as the parent design doc lays out.
|
||||
|
||||
## What we gain at the end
|
||||
|
||||
- **Single source of truth.** Action data lives in tables; no proto representation for the things in scope.
|
||||
- **Native SQL analytics.** Cross-game queries like "actions touching province X" or "all riots in year Y" are one-line SELECTs. No backfill code needed.
|
||||
- **Inspectable saves.** `sqlite3 game.db` shows readable data. Debugging is normal SQL work, not "parse this binary blob."
|
||||
- **Cleaner replay path.** No proto parsing during replay. The `ActionResultProtoConverter` (currently called per replayed action in `replayFrom`) goes away entirely.
|
||||
- **Less code.** ~7 proto files deleted; their generated Scala targets deleted; their converter classes deleted; the dual proto/Scala representation collapses to one.
|
||||
- **Schema migrations as a first-class concept.** Future field additions are SQL migrations, which is the right shape for a system that owns its storage.
|
||||
|
||||
## What we lose
|
||||
|
||||
- **The "edit one proto" workflow** for adding fields. Adding `newFooDelta: Double` to `ChangedProvinceC` is now a Scala edit + SQL migration step + converter update. More steps. Pre-alpha this is cheap; post-alpha (when migrations apply to real data) it's a careful PR but still bounded.
|
||||
- **A safety net for unrecognized fields.** Proto silently keeps unknown fields on parse; SQL doesn't. We accept this because we control both ends.
|
||||
|
||||
## Decisions locked
|
||||
|
||||
1. **Refined option B is the pattern (see "Refinement" in Status; rescinds both "fully relational, no blob fallback" and the original whole-entity-blob-per-entity rule).** Per entity, three tiers: scalar/entity-ref columns (Tier 1) + typed columns for sealed value-oneofs (Tier 2) + per-row leaf-proto blobs in child tables (Tier 3). A whole-entity `<entity>_proto BLOB` is used **only for deep entities** where Tiers 2–3 cost more than they're worth. No child-table explosion for deep aggregates.
|
||||
2. **Classify each entity: all-scalar / refined / deep (supersedes the old "is a blob needed").** *All-scalar* → Tier 1 only, no blob (likely `ChangedFaction`). *Refined* → Tiers 1–3, no whole-entity blob, per-entity proto deletable (`ChangedHero`). *Deep* → Tier 1 + whole-entity `<entity>_proto BLOB`, proto retained (`ChangedProvince`, new-entity snapshots). Default to *refined* for shallow entities; fall back to *deep* only when the residual is too varied for Tiers 2–3.
|
||||
3. **What's retained vs. deleted depends on the classification.** `action_results.payload` (the whole-`ActionResult` blob) still goes away in 4.5h. *Refined* entities' per-entity sub-message protos + converters become dead code, deleted in 4.5i; only the **leaf** protos they use as Tier-3 blob payloads (e.g. `event_for_hero_backstory.proto`) are retained. *Deep* entities' protos + `Quest` stay as whole-entity blob payloads. So 4.5i is no longer near-nothing.
|
||||
4. **Sub-phase ordering** as in the Sequencing table; each decomposition PR splits into `.1` (schema + backfill) and `.2` (live write + read).
|
||||
5. **Phase 4 landed separately, before 4.5a** (historical, unchanged). Cutover validated the lifecycle integration with the simpler schema before decomposition began.
|
||||
@@ -1,291 +0,0 @@
|
||||
# SQLite Game History Design
|
||||
|
||||
## Why
|
||||
|
||||
The current persistence layer is file-based: action results are chunked into `.e0a` files, individual results spill to `.e0i` files for crash recovery, state snapshots live in `.e0s` files, and a `directory.e0i` index tracks chunks. This shape has produced three workarounds in recent history:
|
||||
|
||||
1. A `games.e0es` cache (PR #6706) to avoid re-reading the running-games list.
|
||||
2. Dual-storage in `PersistedActionResult` (proto bytes + parsed proto) to amortize serialization across save flushes.
|
||||
3. Lazy `gameState` (PR #6692) to avoid eager proto conversion on every replayed step, after PR #6691 fixed the `stateAfter` OOM that materialized ~4500 `PersistedActionResult` instances during a stale-LLM-replay path.
|
||||
|
||||
Each is a workaround for the same root cause: file-based random access is expensive, so we cache aggressively, and the cache layers are fragile (one misplaced `.map(_.gameState)` reintroduces the OOM).
|
||||
|
||||
The alternative — SQLite as a per-game container, with proto bytes stored as BLOB columns indexed by action sequence number — gives us random access by construction, removes the need for all three workarounds, and makes idle-game eviction cheap (rehydrate from the DB instead of reparsing files). The pattern is already in production here: `SqliteClientTextStore` does exactly this for the client text cache. We extend the same pattern to game history.
|
||||
|
||||
## Goal & non-goals
|
||||
|
||||
**In scope:**
|
||||
- Per-game SQLite container (`game.db`) holding action history, state snapshots, and shardok results.
|
||||
- A new `SqliteHistory` class implementing the existing `FullGameHistory` trait, drop-in replacement for `PersistedHistory` at the call site.
|
||||
|
||||
**Out of scope (deferred or rejected):**
|
||||
- **Migration of existing saves.** Pre-alpha there are only two users and a handful of test games. Cheaper to nuke the existing save directories at cutover than to write and verify a migration importer. New games created post-cutover land in `game.db` directly; existing games are gone.
|
||||
- The top-level `games.e0es` running-games registry. Stays as-is; its cache (PR #6706) already works.
|
||||
- Idle-game eviction. Enabled by this work but lands in a follow-up phase after `SqliteHistory` is the authoritative path.
|
||||
- A shared cross-game analytics DB. Per-game design supports ad-hoc cross-game queries via iterate-and-aggregate; build the centralized analytics DB only if/when those queries become hot.
|
||||
|
||||
## File layout
|
||||
|
||||
`game.db` lives in the existing per-game save directory, alongside `text_store.db`:
|
||||
|
||||
```
|
||||
${EAGLE_SAVE_DIR}/${gameIdHex}/
|
||||
├── game.db # NEW — action history, snapshots, shardok results
|
||||
└── text_store.db # existing — client text (SqliteClientTextStore)
|
||||
```
|
||||
|
||||
Existing `.e0a` / `.e0s` / `.e0i` / `directory.e0i` files do not coexist with `game.db`: at cutover we nuke save directories one time. There is no migration path and no legacy-file fallback.
|
||||
|
||||
### Why `game.db` and `text_store.db` are separate files (not tables in one DB)
|
||||
|
||||
It's tempting to combine them — one file per game, one connection per game, one cloud-upload story. The reason not to: **writer-lock contention.** SQLite serializes writers per-database file. `SqliteClientTextStore` writes frequently during LLM streaming (one `UPDATE` per token append on `texts.text`). `SqliteHistory` writes per-action during turn commits. Combining them means an in-flight LLM token append can block a turn commit, or vice versa. Separate DBs have separate writer locks and never contend. Subsystem ownership (each package owning its own schema, independent migration paths if the schemas evolve) is a bonus.
|
||||
|
||||
`Persister` integration follows the `SqliteClientTextStore` pattern: `game.db` is uploaded to / downloaded from cloud storage as a single opaque blob, keyed by the filename. On first load, if `game.db` is missing locally, try `persister.retrieveAsStream("game.db")`. If both are missing, this is a new game and we create an empty DB.
|
||||
|
||||
## Schema
|
||||
|
||||
```sql
|
||||
-- Action results, one row per action_result_index.
|
||||
CREATE TABLE action_results (
|
||||
action_seq INTEGER PRIMARY KEY, -- 0-based, dense, never sparse
|
||||
action_result_type INTEGER NOT NULL, -- denormalized for filtering
|
||||
round_id INTEGER NOT NULL, -- denormalized for recentResultsForRound
|
||||
date_year INTEGER, -- denormalized for sinceDate; NULL pre-game-start
|
||||
date_month INTEGER, -- denormalized for sinceDate; NULL pre-game-start
|
||||
payload BLOB NOT NULL -- ActionResult proto bytes
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX idx_action_results_round ON action_results(round_id);
|
||||
CREATE INDEX idx_action_results_date ON action_results(date_year, date_month);
|
||||
-- action_seq is the PK so no index needed there.
|
||||
|
||||
-- State snapshots at chunk boundaries (and the starting state at seq 0).
|
||||
-- boundary_action_seq is the action_seq AFTER which the snapshot reflects state.
|
||||
-- The starting state lives at boundary_action_seq = 0 (state before any actions).
|
||||
CREATE TABLE state_snapshots (
|
||||
boundary_action_seq INTEGER PRIMARY KEY,
|
||||
payload BLOB NOT NULL -- GameState proto bytes
|
||||
) WITHOUT ROWID;
|
||||
|
||||
-- Shardok per-battle results.
|
||||
CREATE TABLE shardok_results (
|
||||
shardok_game_id TEXT NOT NULL,
|
||||
action_seq INTEGER NOT NULL, -- 0-based within this shardok game
|
||||
payload BLOB NOT NULL, -- ShardokActionResult proto bytes
|
||||
PRIMARY KEY (shardok_game_id, action_seq)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
-- Shardok per-game state (one row per shardok_game_id).
|
||||
CREATE TABLE shardok_state (
|
||||
shardok_game_id TEXT PRIMARY KEY,
|
||||
game_state BLOB NOT NULL, -- ShardokGameState proto bytes
|
||||
last_eagle_round_id INTEGER NOT NULL
|
||||
) WITHOUT ROWID;
|
||||
|
||||
-- Shardok per-player results (filtered ActionResultView per faction).
|
||||
CREATE TABLE shardok_player_results (
|
||||
shardok_game_id TEXT NOT NULL,
|
||||
faction_id INTEGER NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
payload BLOB NOT NULL, -- ShardokActionResultView proto bytes
|
||||
PRIMARY KEY (shardok_game_id, faction_id, seq)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
-- Shardok per-player available commands (latest only, keyed by game + faction).
|
||||
CREATE TABLE shardok_player_commands (
|
||||
shardok_game_id TEXT NOT NULL,
|
||||
faction_id INTEGER NOT NULL,
|
||||
payload BLOB, -- ShardokAvailableCommands proto bytes; NULL = no commands
|
||||
PRIMARY KEY (shardok_game_id, faction_id)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
-- Schema version and starting state.
|
||||
CREATE TABLE metadata (
|
||||
key TEXT PRIMARY KEY,
|
||||
value BLOB NOT NULL
|
||||
);
|
||||
-- Seeded with: schema_version=1
|
||||
```
|
||||
|
||||
### Column rationale
|
||||
|
||||
- **`action_seq`** as `INTEGER PRIMARY KEY` (the SQLite rowid alias) — densest possible storage and no separate index; range scans for `since(start)` are O(log n + result count).
|
||||
- **`WITHOUT ROWID`** on tables with synthetic keys to skip the implicit rowid column.
|
||||
- **Denormalized `action_result_type`, `round_id`, `date_year`, `date_month`** — these are the predicates the existing read paths use (`recentResultsForRound`, `sinceDate`). Keeping them as columns avoids parsing the proto blob to filter.
|
||||
- **No `created_at` timestamp** — wall-clock time isn't queried by the History trait, and game date (year/month) is what matters semantically.
|
||||
- **`payload BLOB`** — the proto bytes are the source of truth for the structured action result. Polymorphic action types make a relational schema painful for limited gain; the denormalized columns above cover the queries we need.
|
||||
- **Snapshots keyed by `boundary_action_seq`** — `stateAfter(N)` finds `MAX(boundary_action_seq) WHERE boundary_action_seq <= N`, returns that snapshot, replays forward `N - boundary_action_seq` actions. Snapshot at `0` is the game's starting state.
|
||||
|
||||
### Snapshot strategy
|
||||
|
||||
Match the existing `resultsPerSaveFile = 25` boundary: write a `state_snapshots` row every 25 actions. That gives the same replay-window cost as the current chunk-file design (`stateAfter(N)` replays at most 25 actions to reach an arbitrary point), and matches the cadence developers are already calibrated to.
|
||||
|
||||
Snapshots are GameState proto bytes, identical in shape to today's chunk-file `startingState`. The migration importer derives them directly from the chunk files. New games write a snapshot after every 25th `withNewResults` action.
|
||||
|
||||
## Connection lifecycle
|
||||
|
||||
Mirror `SqliteClientTextStore`:
|
||||
|
||||
- One `Connection` per loaded game, opened when `GamesManager` loads the game, closed when the game is evicted (future eviction work) or the server shuts down.
|
||||
- `Class.forName("org.sqlite.JDBC")` + `DriverManager.getConnection("jdbc:sqlite:${path}")` on open.
|
||||
- `PRAGMA journal_mode = WAL` on every connection open. WAL gives us crash-safe writes and lets readers proceed concurrently with the single writer — important because gRPC stream readers (humanPlayerClientConnectionState) query history mid-turn.
|
||||
- `PRAGMA synchronous = NORMAL` (the WAL-recommended setting; durability is preserved through WAL checkpoint).
|
||||
- `PRAGMA foreign_keys = ON` (defensive; we have no FKs today but cheap to enable).
|
||||
- Auto-commit on by default. Transactions explicitly opened for `withNewResults` (batch of action inserts + optional snapshot) and `truncateTo` (deletes across all tables).
|
||||
|
||||
### Threading
|
||||
|
||||
The current `PersistedHistory` is an immutable case class; `withNewResults` returns a new instance. `SqliteHistory` cannot be pure-immutable (the DB is mutable state) but should present the same interface: methods that "change" the history return `this` after a successful write. The underlying `Connection` is shared.
|
||||
|
||||
JDBC `Connection` is not thread-safe in general; SQLite's JDBC driver serializes operations per-connection. Existing call sites already serialize writes through the `EngineApplier` flow, so single-threaded write access is preserved. Reads from gRPC stream readers can use the same connection — SQLite serializes them transparently, and WAL prevents read-write blocking.
|
||||
|
||||
## Read paths
|
||||
|
||||
How each `FullGameHistory` method maps to SQL:
|
||||
|
||||
| Method | Query |
|
||||
|---|---|
|
||||
| `count` | `SELECT COALESCE(MAX(action_seq), -1) + 1 FROM action_results` (cached as a counter after the first read) |
|
||||
| `last` | `SELECT payload FROM action_results ORDER BY action_seq DESC LIMIT 1` + state from `stateAfter(count)` |
|
||||
| `all` | `SELECT payload FROM action_results ORDER BY action_seq` — used by `GameAdminServiceImpl.getActionDetail` to fetch one action by index. See follow-up note below. |
|
||||
| `since(start)` | `SELECT payload FROM action_results WHERE action_seq >= ? ORDER BY action_seq` |
|
||||
| `sinceDate(date)` | `SELECT payload FROM action_results WHERE (date_year, date_month) >= (?, ?) ORDER BY action_seq` |
|
||||
| `recentResultsForRound(round, pred)` | `SELECT payload FROM action_results WHERE round_id = ? AND action_seq > ? ORDER BY action_seq` where `?` is the cutoff matching current `recentHistory` semantics (last N actions, or all actions for the current round) |
|
||||
| `stateAfter(N)` | Find latest snapshot ≤ N, replay forward via `replayApplier` (same logic as `replayScalaOnlyToState`) |
|
||||
|
||||
For methods that return `Vector[ActionResultWithResultingState]` (with resulting state per row): we **do not** materialize per-row gameStates. Instead, fold the actions through `replayApplier` starting from the latest snapshot ≤ start, producing the states on the fly. This matches what `formAwrs` does today, with one critical difference: no `PersistedActionResult` wrapper is allocated, and no proto-conversion is performed per step. This is the same shape as `replayScalaOnlyToState`, generalized to produce intermediate states.
|
||||
|
||||
### `all()` follow-up
|
||||
|
||||
`GameAdminServiceImpl.getActionDetail` is the one production caller. It fetches a single action by index from the full vector. Either of these is cheaper than materializing the whole history:
|
||||
|
||||
- Replace the call site with `history.since(index).headOption`.
|
||||
- Add a new `actionAt(index): Option[ActionResultWithResultingState]` method and drop `all()` from the trait entirely.
|
||||
|
||||
`SqliteHistory.all` will work — it's just `SELECT * ORDER BY action_seq` — but it's expensive (materializes the full history into memory), so we should switch the admin caller in a small follow-up PR. Not blocking the SQLite work.
|
||||
|
||||
### `recentResultsForRound` semantics
|
||||
|
||||
Today this filters `recentHistory` (the in-memory tail) by `roundId`. In SQLite there's no in-memory/persisted split — all results live in the DB. The semantics shift slightly: return all results matching `round_id` after a configurable cutoff. The cutoff should match today's behavior (results since the start of the current round, or some bounded recent window). Default to "all results with the given `round_id`," which is correct as long as `round_id` uniquely identifies a round across the game's history (it does, per the current `RoundId` model).
|
||||
|
||||
## Write paths
|
||||
|
||||
### `withNewResults(newResults)`
|
||||
|
||||
In a transaction:
|
||||
1. `INSERT INTO action_results (action_seq, action_result_type, round_id, date_year, date_month, payload) VALUES ...` — one row per new result. Use `addBatch()` for multiple.
|
||||
2. For each new result whose `action_seq % 25 == 0` (snapshot boundary), `INSERT INTO state_snapshots(boundary_action_seq, payload) VALUES (?, ?)` with the GameState proto bytes.
|
||||
3. Commit.
|
||||
|
||||
The denormalized columns (`action_result_type`, `round_id`, `date_year`, `date_month`) are extracted from the action's resulting state at insert time. They are immutable once written; if the schema interpretation changes, a migration is required.
|
||||
|
||||
The "individual result for crash recovery" pattern (`.e0i` files) is replaced by: the action result is durably written when the transaction commits. WAL gives us atomicity per transaction. No separate crash-recovery file is needed.
|
||||
|
||||
### `saveNow`
|
||||
|
||||
Becomes a no-op in normal operation — writes are already durable per `withNewResults` commit. We keep the method on the trait for API compatibility but the implementation just returns `this`. (We could call `PRAGMA wal_checkpoint(TRUNCATE)` here to roll the WAL into the main DB file, useful before cloud upload; defer this until we measure WAL growth.)
|
||||
|
||||
### `truncateTo(targetActionCount)`
|
||||
|
||||
In a transaction:
|
||||
1. `DELETE FROM action_results WHERE action_seq >= ?`
|
||||
2. `DELETE FROM state_snapshots WHERE boundary_action_seq > ?`
|
||||
3. Shardok cleanup: `DELETE FROM shardok_results / shardok_state / shardok_player_results / shardok_player_commands WHERE shardok_game_id NOT IN (...)` (the set of battles still outstanding at the truncate point; mirrors `deleteOrphanedShardokFiles`).
|
||||
4. Commit.
|
||||
|
||||
`truncateTo(0)` resets the game; everything after the starting-state snapshot is deleted.
|
||||
|
||||
## Shardok results
|
||||
|
||||
The shardok subsystem currently lives in `.e0s` files (one per battle, full per-battle state). It's loaded selectively at game-load time, only for outstanding battles (see `PersistedHistory.apply` line 248-256).
|
||||
|
||||
Moving to SQLite, the four shardok tables above capture:
|
||||
- `shardok_results` — the per-battle result stream
|
||||
- `shardok_state` — current per-battle state (one row per battle)
|
||||
- `shardok_player_results` — per-faction filtered views
|
||||
- `shardok_player_commands` — current available commands per faction
|
||||
|
||||
`withNewShardokResults` writes to all four in a transaction. `shardokCount`, `shardokGameState`, etc., become single-row indexed lookups.
|
||||
|
||||
The selective-load optimization disappears with SQLite: we don't proactively read anything; queries hit the DB on demand. The "load only outstanding battles" logic is replaced by "query by `shardok_game_id` when needed."
|
||||
|
||||
## Crash recovery
|
||||
|
||||
WAL replaces the `.e0i` individual-result-file mechanism. On startup:
|
||||
- WAL is automatically replayed by SQLite if the previous shutdown was unclean. No application code needed.
|
||||
- A successful commit means durable; an interrupted commit means rolled back. No half-written state visible.
|
||||
|
||||
The current "orphaned individual results on load" path (`loadIndividualResults` in `PersistedHistory.apply`) is gone.
|
||||
|
||||
## Cutover
|
||||
|
||||
No migration path. At cutover:
|
||||
|
||||
1. Stop the server.
|
||||
2. Nuke the contents of `${EAGLE_SAVE_DIR}` (and `${EAGLE_ARCHIVE_DIR}` if there's anything there).
|
||||
3. Deploy.
|
||||
4. New games created post-deploy land in `game.db` directly.
|
||||
|
||||
Pre-alpha there are only two users and a handful of test games; a one-time nuke is cheaper than a verified migration importer. Trade-off accepted by the user explicitly.
|
||||
|
||||
### Testing strategy (no migration)
|
||||
|
||||
Without migration, we don't need equivalence-vs-`PersistedHistory` testing. The correctness gate becomes:
|
||||
|
||||
1. **Adapt `PersistedHistoryTest`** to run against `SqliteHistory` instead. The 890-line existing test suite covers the trait surface exhaustively. Same assertions, new implementation under test.
|
||||
2. **Property-style end-to-end test**: create a synthetic game, run N batches of `withNewResults`, verify that `since(start)`, `stateAfter(N)`, `sinceDate(date)`, `recentResultsForRound`, `truncateTo`, etc., all produce expected values. Run with a deterministic random seed.
|
||||
3. **Manual smoke test in dev**: start a fresh game, play several turns, restart the server, verify saves persist and replay correctly.
|
||||
|
||||
## Cloud-storage integration
|
||||
|
||||
`Persister.save(key, bytes)` and `persister.retrieveAsStream(key)` already handle local-vs-S3 transparently. `SqliteClientTextStore` integrates by treating `text_store.db` as a single binary blob: upload after writes, download before reads.
|
||||
|
||||
`SqliteHistory` will do the same with `game.db`:
|
||||
- **On load:** if local `game.db` missing, try to download via `persister.retrieveAsStream("game.db")`. Fall back to migration from `.e0a` if both are missing.
|
||||
- **On save:** after a meaningful change (turn boundary? configurable cadence?), upload the current `game.db` file to S3. Need to decide the upload trigger — too frequent and we burn S3 PUTs; too rare and crash recovery is lossy.
|
||||
|
||||
**Open question:** the right upload cadence. Today, chunk saves trigger cloud upload at chunk boundaries (every 25 actions). The simplest match: keep that cadence, upload `game.db` once per chunk boundary. We could be smarter (only upload if WAL has been checkpointed; only upload deltas) but those are optimizations.
|
||||
|
||||
## What the trait-level cutover looks like
|
||||
|
||||
`GamesManager` currently has roughly:
|
||||
|
||||
```scala
|
||||
val history: FullGameHistory = PersistedHistory(gameId, persister).getOrElse(...)
|
||||
```
|
||||
|
||||
Becomes:
|
||||
|
||||
```scala
|
||||
val history: FullGameHistory = SqliteHistory.loaded(gameId, persister)
|
||||
```
|
||||
|
||||
Every other consumer of `FullGameHistory` is unchanged.
|
||||
|
||||
## Decisions locked
|
||||
|
||||
1. **Per-game `game.db`** (not shared across games).
|
||||
2. **`game.db` and `text_store.db` stay separate files** (not combined into one DB). Writer-lock contention is the deciding factor.
|
||||
3. **No migration** of existing saves. Nuke save directories at cutover. Pre-alpha trade-off accepted by the user.
|
||||
4. **No feature flag.** Cutover is unconditional. Failures are loud.
|
||||
5. **Cloud upload cadence**: match the existing 25-action chunk cadence. Upload `game.db` once per snapshot boundary.
|
||||
6. **`recentResultsForRound`**: return all results for the given round (no recent-window cap). The `round_id` predicate bounds the scan naturally.
|
||||
7. **WAL checkpointing**: rely on SQLite's auto-checkpoint. Add explicit `PRAGMA wal_checkpoint` only if WAL file growth becomes a problem.
|
||||
8. **Schema versioning**: seed `metadata` with `schema_version = 1`. Schema migration mechanism deferred until we need a v2.
|
||||
9. **`history.all`**: keep on the trait for now; admin call site moved to `since(i).headOption` (or a new `actionAt(i)` method) in a small follow-up PR. Not blocking.
|
||||
|
||||
## Phase plan (revised)
|
||||
|
||||
| Phase | Work | Estimate |
|
||||
|---|---|---|
|
||||
| 0 | This design doc | 2-3 days (in flight) |
|
||||
| 1 | `SqliteHistory` schema + write paths + tests adapted from `PersistedHistoryTest` | ~1 week |
|
||||
| 2 | Read paths + full test parity | ~1 week |
|
||||
| 3 | Shardok results integration | 3-5 days |
|
||||
| 4 | Cutover in `GamesManager` (nuke `${EAGLE_SAVE_DIR}` as part of deploy) | 2-3 days |
|
||||
| 5 | Idle-game eviction | 3-5 days |
|
||||
| 6 | Post-alpha cleanup (delete `PersistedHistory`, `PersistedActionResult`, `PartialGameUtils`, the chunk-file save code, `games.e0es` cache if no longer needed) | 1-2 days |
|
||||
|
||||
**Total: ~3-4 weeks** (down from 5-6 — migration and equivalence-testing dropped).
|
||||
|
||||
The follow-up to migrate `GameAdminServiceImpl.getActionDetail` off `history.all` is a small, independent PR that can land any time.
|
||||
@@ -22,7 +22,7 @@ This avoids the "everything is pink" problem of switching the pipeline on main b
|
||||
| Shaders | 47 | 7 custom Eagle, 1 hex mesh, 20 Polytope, 13 TextMesh Pro, 6 other third-party |
|
||||
| Materials | 325 | Mix of Standard shader, custom, and third-party |
|
||||
| ShaderGraph files | 4 | All TextMesh Pro (URP + HDRP variants already exist) |
|
||||
| Scenes with baked lighting | 5 | Connection, Eagle, Shardok, Shared, Map Editor |
|
||||
| Scenes with baked lighting | 6 | Gameplay, Connection, Eagle, Shardok, Shared, Map Editor |
|
||||
| Post-processing | 1 profile | PPP_Orc.asset (Post Processing Stack v2, FXAA/TAA, AO) |
|
||||
| C# rendering scripts | 16 | Material/shader property manipulation only |
|
||||
| Rendering path | Forward | Matches URP default |
|
||||
@@ -132,7 +132,7 @@ This is the critical path. Without these, the game is unplayable.
|
||||
- Fix any visual differences from lighting model changes
|
||||
|
||||
**Lighting**
|
||||
- Rebake lightmaps for all 5 scenes (Connection, Eagle, Shardok, Shared, Map Editor)
|
||||
- Rebake lightmaps for all 6 scenes (Gameplay, Connection, Eagle, Shardok, Shared, Map Editor)
|
||||
- Configure URP shadow cascade settings to match current quality levels
|
||||
- Verify HDR rendering
|
||||
|
||||
|
||||
+10
-20
@@ -4,18 +4,11 @@ This document describes which quests the AI attempts to complete proactively to
|
||||
|
||||
## Overview
|
||||
|
||||
The AI attempts to complete most quests via `FulfillQuestsCommandSelector`, which is invoked by `MidGameAIClient.chosenFulfillEasyQuestsCommand`.
|
||||
|
||||
Important scope limits:
|
||||
|
||||
- The selector only considers quests from unaffiliated heroes in provinces owned by the acting faction.
|
||||
- It further filters to provinces ruled by that faction's leader.
|
||||
- It only emits a command when the corresponding command is currently available.
|
||||
- Handler order is fixed. The first handler that produces a valid command wins.
|
||||
The AI attempts to complete quests via `FulfillQuestsCommandSelector`, which is invoked by `MidGameAIClient.chosenFulfillEasyQuestsCommand`. The AI only considers quests from unaffiliated heroes in provinces ruled by a faction leader.
|
||||
|
||||
## Quests the AI Actively Completes
|
||||
|
||||
This section lists quests with direct `QuestCommandChooser` handlers in `FulfillQuestsCommandSelector`.
|
||||
The AI processes quest handlers in priority order. The first handler that produces a valid command wins.
|
||||
|
||||
### Diplomacy Quests
|
||||
|
||||
@@ -43,8 +36,7 @@ This section lists quests with direct `QuestCommandChooser` handlers in `Fulfill
|
||||
| `AlmsAcrossRealmQuest` | `AlmsAcrossRealmQuestCommandChooser` | Gives food from the province with the largest surplus |
|
||||
| `GiveToHeroesInProvinceQuest` | `GiveToHeroesInProvinceQuestCommandChooser` | Gives gold to the hero with the lowest loyalty in the specified province |
|
||||
| `GiveToHeroesAcrossRealmQuest` | `GiveToHeroesAcrossRealmQuestCommandChooser` | Gives gold from the province with the most gold available |
|
||||
| `SpendOnFeastsInProvinceQuest` | `SpendOnFeastsQuestCommandChooser` | Spend gold on feasts in the quest holder's province |
|
||||
| `SpendOnFeastsAcrossRealmQuest` | `SpendOnFeastsQuestCommandChooser` | Spend gold on the most expensive currently available feast |
|
||||
| `SpendOnFeastsQuest` | `SpendOnFeastsQuestCommandChooser` | Spend gold on feasts |
|
||||
| `SendSuppliesQuest` | `SendSuppliesQuestCommandChooser` | Send food to the target province |
|
||||
|
||||
### Prisoner Quests
|
||||
@@ -80,6 +72,12 @@ This section lists quests with direct `QuestCommandChooser` handlers in `Fulfill
|
||||
| `BattalionDiversityQuest` | `BattalionDiversityQuestCommandChooser` | Hires one battalion of a missing type to reach 3+ distinct types. Only attempts if the province already has 2+ existing types, has sufficient development (`meetsRequirements`), and gold/food surplus. |
|
||||
| `UpgradeBattalionQuest` | `UpgradeBattalionQuestCommandChooser` | Walks a priority tree per turn: arm-completes (with optional travel and/or food sale setup) → train-completes → march in a pre-qualified neighbor battalion → organize/top-off of the target type → march in an unqualified neighbor battalion → develop Economy/Agriculture → train progress → develop Infrastructure → arm progress. Only toggles Travel when it directly enables an arm-completes finisher. |
|
||||
|
||||
### Riot Quests
|
||||
|
||||
| Quest | Handler | Notes |
|
||||
|-------|---------|-------|
|
||||
| `SuppressRiotByForceQuest` | `CommandChoiceHelpers.handleRiotSelectedCommand` | When this quest exists and the faction has battalions, the AI prefers CrackDown over Give when handling riots. Not a quest command chooser — modifies existing riot handling priority. |
|
||||
|
||||
### Reconnaissance Quests
|
||||
|
||||
| Quest | Handler | Notes |
|
||||
@@ -107,14 +105,6 @@ This section lists quests with direct `QuestCommandChooser` handlers in `Fulfill
|
||||
| `SwearBrotherhoodWithHeroQuest` | `SwearBrotherhoodQuestCommandChooser` | Swear brotherhood with a specific hero |
|
||||
| `DismissSpecificVassalQuest` | `DismissSpecificVassalCommandChooser` | Only if province has more than 2 heroes AND the unaffiliated hero's power >= target hero's power * `RequiredPowerMultiplierForDismiss` |
|
||||
|
||||
## Quests the AI Handles Indirectly
|
||||
|
||||
These are not in `FulfillQuestsCommandSelector`, but other AI command-selection code can still bias toward completing them.
|
||||
|
||||
| Quest | Handler | Notes |
|
||||
|-------|---------|-------|
|
||||
| `SuppressRiotByForceQuest` | `CommandChoiceHelpers.handleRiotSelectedCommand` | When this quest exists and the faction has battalions, the AI prefers CrackDown over Give when handling riots. |
|
||||
|
||||
## Quests the AI Does Not Yet Attempt to Complete
|
||||
|
||||
The following quests have no handler and must be completed naturally through gameplay. They are listed in rough priority order for future implementation.
|
||||
@@ -163,7 +153,7 @@ The AI prioritizes quests in the order they appear in `FulfillQuestsCommandSelec
|
||||
13. ReturnPrisoner
|
||||
14. ReleaseAllPrisoners
|
||||
15. ApprehendOutlaw
|
||||
16. SpendOnFeastsInProvince / SpendOnFeastsAcrossRealm
|
||||
16. SpendOnFeasts
|
||||
17. RestProvince
|
||||
18. DevelopProvinces
|
||||
19. MobilizeProvinces
|
||||
|
||||
@@ -87,7 +87,7 @@ Create these prefabs in `Assets/Eagle/Effects/`:
|
||||
|
||||
### Scene Setup
|
||||
|
||||
Wire up in `Assets/Scenes/Eagle.unity`:
|
||||
Wire up in `Gameplay.unity`:
|
||||
1. Add controller components to the Map GameObject
|
||||
2. Assign `mapContainer`, `centroidsJson` (shared with other controllers)
|
||||
3. Assign the effect prefab for each controller
|
||||
|
||||
@@ -2,7 +2,7 @@ module github.com/nolen777/eagle0
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.25.11
|
||||
toolchain go1.23.3
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.32.8
|
||||
@@ -12,9 +12,9 @@ require (
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6
|
||||
golang.org/x/sys v0.33.0
|
||||
google.golang.org/grpc v1.71.0
|
||||
google.golang.org/protobuf v1.36.10
|
||||
golang.org/x/sys v0.30.0
|
||||
google.golang.org/grpc v1.68.0
|
||||
google.golang.org/protobuf v1.36.3
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -32,7 +32,5 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.6 // indirect
|
||||
github.com/aws/smithy-go v1.22.1 // indirect
|
||||
golang.org/x/net v0.34.0 // indirect
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect
|
||||
)
|
||||
|
||||
@@ -43,24 +43,14 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 h1:VQpB2SpK88C6B5lPHTuSZKb2Qee1QWwiFlC5CKY4AW0=
|
||||
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6/go.mod h1:yE65LFCeWf4kyWD5re+h4XNvOHJEXOCOuJZ4v8l5sgk=
|
||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
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/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50=
|
||||
google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0=
|
||||
google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA=
|
||||
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
|
||||
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
|
||||
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=
|
||||
google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
|
||||
+443
-1901
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,8 @@
|
||||
|
||||
set -e
|
||||
|
||||
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
|
||||
|
||||
# Run gazelle
|
||||
bazel run //:gazelle
|
||||
bazel run //:gazelle 2>/dev/null
|
||||
|
||||
# Check if any BUILD files were modified
|
||||
if ! git diff --quiet -- '*.bazel' '**/BUILD' 'WORKSPACE*'; then
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Pre-commit hook wrapper for scalafmt.
|
||||
# Runs the repository-pinned scalafmt CLI through Bazel.
|
||||
|
||||
set -e
|
||||
|
||||
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel)"
|
||||
files=()
|
||||
for file in "$@"; do
|
||||
case "$file" in
|
||||
/*) files+=("$file") ;;
|
||||
*) files+=("$repo_root/$file") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
bazel run //tools:scalafmt -- --config "$repo_root/.scalafmt.conf" -i "${files[@]}"
|
||||
+13
-26
@@ -1,12 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Keeps Bazel macOS builds in sync with the installed Xcode version.
|
||||
# Keeps Bazel mactools builds in sync with the installed Xcode version.
|
||||
#
|
||||
# Needed on macOS runners and developer machines because Bazel's generated
|
||||
# local_config_xcode/local_config_apple_cc repositories bake in the discovered
|
||||
# Xcode version. If Xcode is upgraded in place, the DEVELOPER_DIR path stays the
|
||||
# same, so Bazel can otherwise keep using stale Xcode metadata.
|
||||
# Only needed on runners that do mactools builds (Mac/Sparkle). Non-Mac builds
|
||||
# don't run this script; their apple_cc_autoconf stays cached because none of
|
||||
# its environ vars change, so they're unaffected by Xcode version changes.
|
||||
#
|
||||
# 1. Writes .bazelrc.xcode with macOS-scoped flags:
|
||||
# 1. Writes .bazelrc.xcode with mactools-scoped flags:
|
||||
# - action_env for remote cache key invalidation
|
||||
# - DEVELOPER_DIR pointing to the active Xcode
|
||||
#
|
||||
@@ -18,24 +17,17 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DEVELOPER_DIR=""
|
||||
if [ -d "/Applications/Xcode.app/Contents/Developer" ]; then
|
||||
DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
|
||||
else
|
||||
DEVELOPER_DIR=$(xcode-select -p 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
XCODE_BUILD_VERSION=$(DEVELOPER_DIR="$DEVELOPER_DIR" xcodebuild -version 2>/dev/null | awk '/Build version/ {print $3}' || true)
|
||||
XCODE_BUILD_VERSION=$(xcodebuild -version 2>/dev/null | awk '/Build version/ {print $3}')
|
||||
|
||||
if [ -z "$XCODE_BUILD_VERSION" ]; then
|
||||
echo "ERROR: Could not detect full Xcode build version."
|
||||
echo "mactools/Sparkle builds require full Xcode, not just Command Line Tools."
|
||||
echo "Install Xcode at /Applications/Xcode.app or select it with xcode-select."
|
||||
exit 1
|
||||
echo "Warning: Could not detect Xcode build version, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
DEVELOPER_DIR=$(xcode-select -p 2>/dev/null)
|
||||
|
||||
BAZELRC_XCODE=".bazelrc.xcode"
|
||||
EXPECTED_LINE="common:macos --action_env=XCODE_BUILD_VERSION=${XCODE_BUILD_VERSION}"
|
||||
EXPECTED_LINE="common:mactools --action_env=XCODE_BUILD_VERSION=${XCODE_BUILD_VERSION}"
|
||||
|
||||
# Check if the file already has the right version (first line is the version marker)
|
||||
if [ -f "$BAZELRC_XCODE" ]; then
|
||||
@@ -57,11 +49,6 @@ bazel clean --expunge 2>/dev/null || true
|
||||
|
||||
cat > "$BAZELRC_XCODE" << EOF
|
||||
${EXPECTED_LINE}
|
||||
common:macos --repo_env=DEVELOPER_DIR=${DEVELOPER_DIR}
|
||||
common:mactools --repo_env=DEVELOPER_DIR=${DEVELOPER_DIR}
|
||||
EOF
|
||||
|
||||
if [ -n "${GITHUB_ENV:-}" ]; then
|
||||
echo "DEVELOPER_DIR=${DEVELOPER_DIR}" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
echo "Updated ${BAZELRC_XCODE} — next macOS Bazel build will rebuild with Xcode ${XCODE_BUILD_VERSION}"
|
||||
echo "Updated ${BAZELRC_XCODE} — next mactools build will rebuild with Xcode ${XCODE_BUILD_VERSION}"
|
||||
|
||||
@@ -66,14 +66,6 @@ cc_library(
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "shardok_latency_trace",
|
||||
srcs = ["ShardokLatencyTrace.cpp"],
|
||||
hdrs = ["ShardokLatencyTrace.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "repeated_proto_utils",
|
||||
srcs = [],
|
||||
|
||||
@@ -88,11 +88,6 @@ StdLibraryGenerator::StdLibraryGenerator() : RandomGenerator() {
|
||||
engine.seed(static_cast<std::mt19937_64::result_type>(std::time(nullptr)));
|
||||
}
|
||||
|
||||
StdLibraryGenerator::StdLibraryGenerator(const std::mt19937_64::result_type seed)
|
||||
: RandomGenerator() {
|
||||
engine.seed(seed);
|
||||
}
|
||||
|
||||
auto StdLibraryGenerator::IntBetween(const int min, const int max) -> int {
|
||||
std::uniform_int_distribution<int> unifInt(min, max - 1);
|
||||
return unifInt(engine);
|
||||
|
||||
@@ -51,7 +51,6 @@ private:
|
||||
|
||||
public:
|
||||
StdLibraryGenerator();
|
||||
explicit StdLibraryGenerator(std::mt19937_64::result_type seed);
|
||||
|
||||
auto IntBetween(int min, int max) -> int override; // inclusive, exclusive (min<=return<max)
|
||||
};
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
#include "ShardokLatencyTrace.hpp"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#include <mutex>
|
||||
#include <sstream>
|
||||
|
||||
namespace net::eagle0::common {
|
||||
namespace {
|
||||
|
||||
auto ReadEnvEnabled() -> bool {
|
||||
const char* value = std::getenv("EAGLE0_SHARDOK_LATENCY_TRACE");
|
||||
if (value == nullptr) { return false; }
|
||||
const std::string text(value);
|
||||
return text == "1" || text == "true" || text == "TRUE" || text == "yes" || text == "YES";
|
||||
}
|
||||
|
||||
auto TraceEnabled() -> bool {
|
||||
static const bool enabled = ReadEnvEnabled();
|
||||
return enabled;
|
||||
}
|
||||
|
||||
auto ReadGameFilter() -> std::string {
|
||||
const char* value = std::getenv("EAGLE0_SHARDOK_LATENCY_GAME_ID");
|
||||
return value == nullptr ? "" : std::string(value);
|
||||
}
|
||||
|
||||
auto GameFilter() -> const std::string& {
|
||||
static const std::string filter = ReadGameFilter();
|
||||
return filter;
|
||||
}
|
||||
|
||||
auto JsonEscape(std::string_view value) -> std::string {
|
||||
std::ostringstream out;
|
||||
for (const char c : value) {
|
||||
switch (c) {
|
||||
case '"': out << "\\\""; break;
|
||||
case '\\': out << "\\\\"; break;
|
||||
case '\n': out << "\\n"; break;
|
||||
case '\r': out << "\\r"; break;
|
||||
case '\t': out << "\\t"; break;
|
||||
default: out << c; break;
|
||||
}
|
||||
}
|
||||
return out.str();
|
||||
}
|
||||
|
||||
auto LogMutex() -> std::mutex& {
|
||||
static auto mutex = new std::mutex();
|
||||
return *mutex;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
auto ShardokLatencyTraceEnabledForGame(std::string_view gameId) -> bool {
|
||||
if (!TraceEnabled()) { return false; }
|
||||
const std::string& filter = GameFilter();
|
||||
return filter.empty() || filter == gameId;
|
||||
}
|
||||
|
||||
void EmitShardokLatencyTrace(
|
||||
std::string_view event,
|
||||
std::string_view gameId,
|
||||
const ShardokLatencyFields& fields,
|
||||
std::chrono::steady_clock::duration duration) {
|
||||
if (!ShardokLatencyTraceEnabledForGame(gameId)) { return; }
|
||||
|
||||
const auto durationMs =
|
||||
std::chrono::duration_cast<std::chrono::microseconds>(duration).count() / 1000.0;
|
||||
|
||||
std::ostringstream out;
|
||||
out << "{\"event\":\"shardok_latency\","
|
||||
<< "\"component\":\"shardok\","
|
||||
<< "\"span\":\"" << JsonEscape(event) << "\","
|
||||
<< "\"game_id\":\"" << JsonEscape(gameId) << "\","
|
||||
<< "\"duration_ms\":" << durationMs;
|
||||
for (const auto& [key, value] : fields) {
|
||||
out << ",\"" << JsonEscape(key) << "\":\"" << JsonEscape(value) << "\"";
|
||||
}
|
||||
out << "}";
|
||||
|
||||
std::scoped_lock lock(LogMutex());
|
||||
std::cerr << out.str() << std::endl;
|
||||
}
|
||||
|
||||
ScopedShardokLatencyTrace::ScopedShardokLatencyTrace(
|
||||
std::string event,
|
||||
std::string gameId,
|
||||
ShardokLatencyFields fields)
|
||||
: event_(std::move(event)),
|
||||
gameId_(std::move(gameId)),
|
||||
fields_(std::move(fields)) {
|
||||
if (ShardokLatencyTraceEnabledForGame(gameId_)) { start_ = std::chrono::steady_clock::now(); }
|
||||
}
|
||||
|
||||
ScopedShardokLatencyTrace::~ScopedShardokLatencyTrace() {
|
||||
if (!start_.has_value()) { return; }
|
||||
EmitShardokLatencyTrace(event_, gameId_, fields_, std::chrono::steady_clock::now() - *start_);
|
||||
}
|
||||
|
||||
} // namespace net::eagle0::common
|
||||
@@ -1,42 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace net::eagle0::common {
|
||||
|
||||
using ShardokLatencyFields = std::vector<std::pair<std::string, std::string>>;
|
||||
|
||||
auto ShardokLatencyTraceEnabledForGame(std::string_view gameId) -> bool;
|
||||
|
||||
void EmitShardokLatencyTrace(
|
||||
std::string_view event,
|
||||
std::string_view gameId,
|
||||
const ShardokLatencyFields& fields,
|
||||
std::chrono::steady_clock::duration duration);
|
||||
|
||||
class ScopedShardokLatencyTrace {
|
||||
public:
|
||||
ScopedShardokLatencyTrace(
|
||||
std::string event,
|
||||
std::string gameId,
|
||||
ShardokLatencyFields fields = {});
|
||||
~ScopedShardokLatencyTrace();
|
||||
|
||||
ScopedShardokLatencyTrace(const ScopedShardokLatencyTrace&) = delete;
|
||||
auto operator=(const ScopedShardokLatencyTrace&) -> ScopedShardokLatencyTrace& = delete;
|
||||
ScopedShardokLatencyTrace(ScopedShardokLatencyTrace&&) = delete;
|
||||
auto operator=(ScopedShardokLatencyTrace&&) -> ScopedShardokLatencyTrace& = delete;
|
||||
|
||||
private:
|
||||
std::string event_;
|
||||
std::string gameId_;
|
||||
ShardokLatencyFields fields_;
|
||||
std::optional<std::chrono::steady_clock::time_point> start_;
|
||||
};
|
||||
|
||||
} // namespace net::eagle0::common
|
||||
@@ -21,14 +21,7 @@ namespace shardok::mcts {
|
||||
|
||||
AbstractMCTSAI::AbstractMCTSAI(MCTSPlayerId playerId, MCTSConfig config)
|
||||
: playerId_(playerId),
|
||||
config_(config),
|
||||
randomEngine_(config.randomSeed == 0 ? std::random_device{}() : config.randomSeed) {}
|
||||
|
||||
void AbstractMCTSAI::SetConfig(const MCTSConfig& newConfig) {
|
||||
config_ = newConfig;
|
||||
std::lock_guard lock(randomEngineMutex_);
|
||||
randomEngine_.seed(newConfig.randomSeed == 0 ? std::random_device{}() : newConfig.randomSeed);
|
||||
}
|
||||
config_(config) {}
|
||||
|
||||
auto AbstractMCTSAI::Search(
|
||||
const MCTSGameEngine& engine,
|
||||
@@ -357,19 +350,22 @@ auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine)
|
||||
// fair UCB comparison with non-chance actions like END_TURN
|
||||
{
|
||||
double expectedImmediate = 0.0;
|
||||
for (size_t i = 0; i < node->outcomeProbabilities.size(); i++) {
|
||||
double totalProbability = 0.0;
|
||||
for (size_t i = 0; i < node->children.size(); i++) {
|
||||
const double prob = node->outcomeProbabilities[i];
|
||||
const double childImmediate = i < node->children.size()
|
||||
? node->children[i]->immediateScore
|
||||
: node->unvisitedOutcomeScore;
|
||||
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;
|
||||
}
|
||||
node->immediateScore = expectedImmediate;
|
||||
// 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();
|
||||
@@ -400,14 +396,8 @@ auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine)
|
||||
|
||||
const auto& action = nodeActions[actionIndex];
|
||||
|
||||
double totalActionWeight = 0.0;
|
||||
for (const double weight : actionWeights) { totalActionWeight += std::max(0.0, weight); }
|
||||
|
||||
const double rawActionWeight =
|
||||
actionIndex < actionWeights.size() ? std::max(0.0, actionWeights[actionIndex]) : 1.0;
|
||||
const double actionWeight = totalActionWeight > 0.0
|
||||
? rawActionWeight / totalActionWeight
|
||||
: 1.0 / static_cast<double>(nodeActions.size());
|
||||
const double actionWeight =
|
||||
actionIndex < actionWeights.size() ? actionWeights[actionIndex] : 1.0;
|
||||
|
||||
// Check if this action requires a chance node
|
||||
if (action->requiresChanceNode()) {
|
||||
@@ -427,16 +417,15 @@ auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine)
|
||||
// Get outcome information from engine
|
||||
const auto outcomeInfo = engine.getBinaryOutcomeInfo(*node->gameState, *action);
|
||||
|
||||
// Set up outcome metadata.
|
||||
// Set up outcome metadata (2 outcomes for binary actions)
|
||||
chanceNode->outcomeProbabilities = outcomeInfo.getProbabilities();
|
||||
chanceNode->outcomeRolls = outcomeInfo.getRepresentativeRolls();
|
||||
chanceNode->totalActions = chanceNode->outcomeProbabilities.size();
|
||||
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;
|
||||
chanceNode->unvisitedOutcomeScore = chanceNode->immediateScore;
|
||||
|
||||
// Set parent and add to children
|
||||
chanceNode->parent = node;
|
||||
@@ -613,20 +602,29 @@ auto AbstractMCTSAI::MCTSBackpropagation(
|
||||
// Chance nodes: compute expected value (weighted average of outcomes)
|
||||
// lookaheadScore = sum(probability[i] * childValue[i])
|
||||
double expectedValue = 0.0;
|
||||
bool hasVisitedChild = false;
|
||||
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
|
||||
|
||||
for (size_t i = 0; i < node->outcomeProbabilities.size(); i++) {
|
||||
const double probability = node->outcomeProbabilities[i];
|
||||
double childValue = node->unvisitedOutcomeScore;
|
||||
if (i < node->children.size() && node->children[i]->visitCount > 0) {
|
||||
childValue = node->children[i]->lookaheadScore;
|
||||
hasVisitedChild = true;
|
||||
}
|
||||
const double childValue = child->lookaheadScore;
|
||||
expectedValue += probability * childValue;
|
||||
totalProbability += probability;
|
||||
visitedChildCount++;
|
||||
}
|
||||
|
||||
// Use expected value if we have visited outcomes, else use average
|
||||
if (hasVisitedChild) {
|
||||
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
|
||||
@@ -707,21 +705,23 @@ auto AbstractMCTSAI::SelectSimulationAction(
|
||||
"MCTS tree building or game state");
|
||||
}
|
||||
|
||||
thread_local std::mt19937 gen(std::random_device{}());
|
||||
|
||||
switch (config_.simulationPolicy) {
|
||||
case MCTSSimulationPolicy::RANDOM: {
|
||||
std::uniform_int_distribution<size_t> dis(0, actions.size() - 1);
|
||||
return SampleDistribution(dis);
|
||||
return dis(gen);
|
||||
}
|
||||
|
||||
case MCTSSimulationPolicy::FILTERED_RANDOM: {
|
||||
if (const auto filteredIndices = engine.filterActions(actions, state);
|
||||
!filteredIndices.empty()) {
|
||||
std::uniform_int_distribution<size_t> dis(0, filteredIndices.size() - 1);
|
||||
return filteredIndices[SampleDistribution(dis)];
|
||||
return filteredIndices[dis(gen)];
|
||||
}
|
||||
// Fall back to random
|
||||
std::uniform_int_distribution<size_t> dis(0, actions.size() - 1);
|
||||
return SampleDistribution(dis);
|
||||
return dis(gen);
|
||||
}
|
||||
|
||||
case MCTSSimulationPolicy::BEST_IMMEDIATE: {
|
||||
@@ -790,7 +790,7 @@ auto AbstractMCTSAI::SelectSimulationAction(
|
||||
|
||||
// Select based on weights
|
||||
std::discrete_distribution<> dis(weights.begin(), weights.end());
|
||||
return scores[SampleDistribution(dis)].first;
|
||||
return scores[dis(gen)].first;
|
||||
}
|
||||
|
||||
case MCTSSimulationPolicy::WEIGHTED_HEURISTIC: {
|
||||
@@ -820,13 +820,13 @@ auto AbstractMCTSAI::SelectSimulationAction(
|
||||
|
||||
// Select based on heuristic weights using discrete_distribution
|
||||
std::discrete_distribution<> dis(validWeights.begin(), validWeights.end());
|
||||
return validIndices[SampleDistribution(dis)];
|
||||
return validIndices[dis(gen)];
|
||||
}
|
||||
}
|
||||
|
||||
// Default to random
|
||||
std::uniform_int_distribution<size_t> dis(0, actions.size() - 1);
|
||||
return SampleDistribution(dis);
|
||||
return dis(gen);
|
||||
}
|
||||
|
||||
auto AbstractMCTSAI::FindNodeAtDepthWithHash(
|
||||
|
||||
@@ -7,8 +7,6 @@
|
||||
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <random>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
@@ -43,7 +41,7 @@ public:
|
||||
|
||||
// Configuration
|
||||
[[nodiscard]] auto GetConfig() const -> const MCTSConfig& { return config_; }
|
||||
void SetConfig(const MCTSConfig& newConfig);
|
||||
void SetConfig(const MCTSConfig& newConfig) { config_ = newConfig; }
|
||||
|
||||
[[nodiscard]] auto FindNodeAtDepthWithHash(
|
||||
const MCTSNode* root,
|
||||
@@ -53,8 +51,6 @@ public:
|
||||
private:
|
||||
MCTSPlayerId playerId_;
|
||||
MCTSConfig config_;
|
||||
mutable std::mt19937 randomEngine_;
|
||||
mutable std::mutex randomEngineMutex_;
|
||||
|
||||
// Transposition table: maps state hash -> minimum depth at which state was reached
|
||||
// Used to detect and penalize longer paths to the same game state
|
||||
@@ -89,13 +85,6 @@ private:
|
||||
const std::vector<std::unique_ptr<MCTSAction>>& actions,
|
||||
bool isMaximizing) const -> size_t;
|
||||
|
||||
template<typename Distribution>
|
||||
[[nodiscard]] auto SampleDistribution(Distribution& distribution) const ->
|
||||
typename Distribution::result_type {
|
||||
std::lock_guard lock(randomEngineMutex_);
|
||||
return distribution(randomEngine_);
|
||||
}
|
||||
|
||||
// Logging
|
||||
static auto LogSearchResults(
|
||||
const MCTSNode* rootNode,
|
||||
@@ -114,4 +103,4 @@ private:
|
||||
} // namespace mcts
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_ABSTRACT_MCTSAI_HPP
|
||||
#endif // EAGLE0_ABSTRACT_MCTSAI_HPP
|
||||
@@ -6,7 +6,6 @@ cc_library(
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai_testing_common:__pkg__",
|
||||
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
],
|
||||
|
||||
@@ -54,7 +54,6 @@ struct MCTSNode {
|
||||
// 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
|
||||
double unvisitedOutcomeScore = 0.0; // Default value for outcomes not expanded yet
|
||||
|
||||
// Game context
|
||||
MCTSPlayerId playerId;
|
||||
@@ -275,4 +274,4 @@ struct MCTSNode {
|
||||
} // namespace mcts
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_ABSTRACT_MCTSNODE_HPP
|
||||
#endif // EAGLE0_ABSTRACT_MCTSNODE_HPP
|
||||
@@ -51,11 +51,10 @@ struct MCTSConfig {
|
||||
// When evaluating a leaf at playerFlips < maxSimulationFlips,
|
||||
// simulate forward to this phase for fair comparison
|
||||
// (default 0 = evaluate leaves as-is, backward compatible)
|
||||
uint32_t randomSeed = 0; // 0 = seed nondeterministically
|
||||
std::string debugDumpPath = ""; // If non-empty, dump MCTS tree to this file path
|
||||
};
|
||||
|
||||
} // namespace mcts
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_MCTS_TYPES_HPP
|
||||
#endif // EAGLE0_MCTS_TYPES_HPP
|
||||
@@ -54,12 +54,6 @@ static auto IsDeterministic(const CommandType type) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
static auto RandomnessSampleForRepeat(const int repeatIteration, const int maxRepeatCount)
|
||||
-> double {
|
||||
if (maxRepeatCount <= 1) { return 0.5; }
|
||||
return static_cast<double>(repeatIteration) / static_cast<double>(maxRepeatCount - 1);
|
||||
}
|
||||
|
||||
// Helper function to sort commands by score
|
||||
static auto CommandSorter(
|
||||
const AICommandEvaluator::IndexAndScore& l,
|
||||
@@ -91,15 +85,15 @@ auto AICommandEvaluator::PerformLookahead(
|
||||
const ScoreValue currentUtility,
|
||||
const AIStrategy& attackerStrategy,
|
||||
const CoordsSet& allCastleCoords,
|
||||
std::chrono::steady_clock::time_point deadline) const -> std::future<EvaluationResult> {
|
||||
std::chrono::steady_clock::time_point deadline) const -> std::future<ScoreValue> {
|
||||
// Check transposition table before expensive computation
|
||||
auto cachedScore =
|
||||
g_transpositionTable.probe(innerEngine->GetCurrentGameState(), remainingLookahead, pid);
|
||||
|
||||
if (cachedScore.has_value()) {
|
||||
// Return cached result immediately
|
||||
std::promise<EvaluationResult> p;
|
||||
p.set_value(EvaluationResult{.score = *cachedScore, .completed = true});
|
||||
std::promise<ScoreValue> p;
|
||||
p.set_value(*cachedScore);
|
||||
return p.get_future();
|
||||
}
|
||||
const auto nextUtility = currentUtility;
|
||||
@@ -111,8 +105,8 @@ auto AICommandEvaluator::PerformLookahead(
|
||||
// table
|
||||
g_transpositionTable.store(innerEngine->GetCurrentGameState(), 1, pid, nextUtility);
|
||||
|
||||
std::promise<EvaluationResult> p;
|
||||
p.set_value(EvaluationResult{.score = nextUtility, .completed = true});
|
||||
std::promise<ScoreValue> p;
|
||||
p.set_value(nextUtility);
|
||||
return p.get_future();
|
||||
}
|
||||
|
||||
@@ -137,18 +131,16 @@ auto AICommandEvaluator::PerformLookahead(
|
||||
innerEngine,
|
||||
pid,
|
||||
nextUtility,
|
||||
remainingLookahead]() mutable -> EvaluationResult {
|
||||
const auto bestCommand = bestCommandFuture.get();
|
||||
if (!bestCommand.completed) {
|
||||
return EvaluationResult{.score = nextUtility, .completed = false};
|
||||
}
|
||||
remainingLookahead]() mutable -> ScoreValue {
|
||||
const auto [index, type, lookaheadScore, immediateScore] =
|
||||
bestCommandFuture.get();
|
||||
|
||||
ScoreValue resultScore;
|
||||
if (auto& nextCommand = innerEngine->GetAvailableCommandsForAIPlayer(pid)->at(
|
||||
bestCommand.index);
|
||||
if (auto& nextCommand =
|
||||
innerEngine->GetAvailableCommandsForAIPlayer(pid)->at(index);
|
||||
nextCommand->GetCommandType() !=
|
||||
net::eagle0::shardok::common::END_TURN_COMMAND) {
|
||||
resultScore = bestCommand.immediateScore;
|
||||
resultScore = immediateScore;
|
||||
} else {
|
||||
resultScore = nextUtility;
|
||||
}
|
||||
@@ -160,7 +152,7 @@ auto AICommandEvaluator::PerformLookahead(
|
||||
pid,
|
||||
resultScore);
|
||||
|
||||
return EvaluationResult{.score = resultScore, .completed = true};
|
||||
return resultScore;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -168,8 +160,8 @@ auto AICommandEvaluator::PerformLookahead(
|
||||
g_transpositionTable
|
||||
.store(innerEngine->GetCurrentGameState(), remainingLookahead, pid, nextUtility);
|
||||
|
||||
std::promise<EvaluationResult> p;
|
||||
p.set_value(EvaluationResult{.score = nextUtility, .completed = true});
|
||||
std::promise<ScoreValue> p;
|
||||
p.set_value(nextUtility);
|
||||
return p.get_future();
|
||||
}
|
||||
|
||||
@@ -188,10 +180,10 @@ auto AICommandEvaluator::EvaluateWithRandomness(
|
||||
|
||||
// Check if we've exceeded the deadline
|
||||
if (std::chrono::steady_clock::now() > deadline) {
|
||||
std::promise<EvaluationResult> p;
|
||||
p.set_value(EvaluationResult{.score = 0.0, .completed = false});
|
||||
// Return with a default score and an empty future that resolves immediately
|
||||
std::promise<ScoreValue> p;
|
||||
p.set_value(0.0); // Default timeout score
|
||||
returnValue.immediateScore = 0.0;
|
||||
returnValue.completed = false;
|
||||
returnValue.lookaheadScore = p.get_future();
|
||||
return returnValue;
|
||||
}
|
||||
@@ -206,12 +198,11 @@ auto AICommandEvaluator::EvaluateWithRandomness(
|
||||
allCastleCoords);
|
||||
|
||||
returnValue.immediateScore = innerUtility;
|
||||
returnValue.completed = true;
|
||||
|
||||
if (remainingLookahead <= 0) {
|
||||
std::promise<EvaluationResult> p;
|
||||
std::promise<ScoreValue> p;
|
||||
returnValue.lookaheadScore = p.get_future();
|
||||
p.set_value(EvaluationResult{.score = innerUtility, .completed = true});
|
||||
p.set_value(innerUtility);
|
||||
} else {
|
||||
auto lookaheadLambda = [this,
|
||||
pid,
|
||||
@@ -222,7 +213,7 @@ auto AICommandEvaluator::EvaluateWithRandomness(
|
||||
attackerStrategy,
|
||||
innerUtility,
|
||||
&allCastleCoords,
|
||||
deadline]() -> EvaluationResult {
|
||||
deadline]() -> ScoreValue {
|
||||
auto lookaheadFuture = PerformLookahead(
|
||||
pid,
|
||||
isDefender,
|
||||
@@ -240,7 +231,7 @@ auto AICommandEvaluator::EvaluateWithRandomness(
|
||||
auto launchPolicy = remainingLookahead == 1 ? std::launch::async : std::launch::deferred;
|
||||
returnValue.lookaheadScore = std::async(launchPolicy, lookaheadLambda);
|
||||
#else
|
||||
std::promise<EvaluationResult> p;
|
||||
std::promise<ScoreValue> p;
|
||||
returnValue.lookaheadScore = p.get_future();
|
||||
auto lambdaResult = lookaheadLambda();
|
||||
p.set_value(lambdaResult);
|
||||
@@ -325,8 +316,7 @@ auto AICommandEvaluator::FindBestCommand(
|
||||
size_t index;
|
||||
CommandType type;
|
||||
ScoreValue immediateScore;
|
||||
bool immediateCompleted = true;
|
||||
std::vector<std::future<EvaluationResult>> lookaheadFutures;
|
||||
std::vector<std::future<ScoreValue>> lookaheadFutures;
|
||||
};
|
||||
|
||||
std::vector<CommandEvaluation> commandEvaluations(commandCount);
|
||||
@@ -340,12 +330,12 @@ auto AICommandEvaluator::FindBestCommand(
|
||||
commandEvaluations[index].type = guessedCommandType;
|
||||
|
||||
if (guessedCommandType == net::eagle0::shardok::common::END_TURN_COMMAND) {
|
||||
std::promise<EvaluationResult> p;
|
||||
std::promise<ScoreValue> p;
|
||||
commandEvaluations[index].lookaheadFutures.push_back(p.get_future());
|
||||
p.set_value(EvaluationResult{.score = currentUtility, .completed = true});
|
||||
p.set_value(currentUtility);
|
||||
commandEvaluations[index].immediateScore = currentUtility;
|
||||
} else if (IsDeterministic(guessedCommandType)) {
|
||||
auto evaluation = EvaluateWithRandomness(
|
||||
auto [immediateScore, lookaheadScore] = EvaluateWithRandomness(
|
||||
pid,
|
||||
isDefender,
|
||||
originalIndex,
|
||||
@@ -357,16 +347,14 @@ auto AICommandEvaluator::FindBestCommand(
|
||||
allCastleCoords,
|
||||
deadline);
|
||||
|
||||
commandEvaluations[index].immediateScore = evaluation.immediateScore;
|
||||
commandEvaluations[index].immediateCompleted = evaluation.completed;
|
||||
commandEvaluations[index].lookaheadFutures.push_back(
|
||||
std::move(evaluation.lookaheadScore));
|
||||
commandEvaluations[index].immediateScore = immediateScore;
|
||||
commandEvaluations[index].lookaheadFutures.push_back(std::move(lookaheadScore));
|
||||
} else if (guessedDescriptor->HasOdds()) {
|
||||
const auto successChancePercentile = guessedDescriptor->GetOddsPercentile();
|
||||
const double successChance = static_cast<double>(successChancePercentile) / 100.0;
|
||||
|
||||
// Success attempt uses 1.0 - (successChance / 2) as the roll
|
||||
auto successEvaluation = EvaluateWithRandomness(
|
||||
auto [successImmediateScore, successLookaheadScore] = EvaluateWithRandomness(
|
||||
pid,
|
||||
isDefender,
|
||||
originalIndex,
|
||||
@@ -380,7 +368,7 @@ auto AICommandEvaluator::FindBestCommand(
|
||||
deadline);
|
||||
|
||||
// Failure attempt uses the average of (1 - successChance) and 0 as the roll
|
||||
auto failureEvaluation = EvaluateWithRandomness(
|
||||
auto [failureImmediateScore, failureLookaheadScore] = EvaluateWithRandomness(
|
||||
pid,
|
||||
isDefender,
|
||||
originalIndex,
|
||||
@@ -393,35 +381,24 @@ auto AICommandEvaluator::FindBestCommand(
|
||||
allCastleCoords,
|
||||
deadline);
|
||||
|
||||
commandEvaluations[index].immediateCompleted =
|
||||
failureEvaluation.completed && successEvaluation.completed;
|
||||
commandEvaluations[index].immediateScore = std::lerp(
|
||||
failureEvaluation.immediateScore,
|
||||
successEvaluation.immediateScore,
|
||||
successChance);
|
||||
commandEvaluations[index].immediateScore =
|
||||
std::lerp(failureImmediateScore, successImmediateScore, successChance);
|
||||
|
||||
auto successSF = successEvaluation.lookaheadScore.share();
|
||||
auto failureSF = failureEvaluation.lookaheadScore.share();
|
||||
auto successSF = successLookaheadScore.share();
|
||||
auto failureSF = failureLookaheadScore.share();
|
||||
commandEvaluations[index].lookaheadFutures.push_back(std::async(
|
||||
std::launch::deferred,
|
||||
[successSF, failureSF, successChance]() -> EvaluationResult {
|
||||
const auto failure = failureSF.get();
|
||||
const auto success = successSF.get();
|
||||
if (!failure.completed || !success.completed) {
|
||||
return EvaluationResult{.score = 0.0, .completed = false};
|
||||
}
|
||||
return EvaluationResult{
|
||||
.score = std::lerp(failure.score, success.score, successChance),
|
||||
.completed = true};
|
||||
[successSF, failureSF, successChance]() -> double {
|
||||
return std::lerp(failureSF.get(), successSF.get(), successChance);
|
||||
}));
|
||||
} else {
|
||||
ScoreValue sum = 0.0;
|
||||
const int sampleCount = std::max(1, maxRepeatCount);
|
||||
for (int repeatIteration = 0; repeatIteration < sampleCount; repeatIteration++) {
|
||||
for (int repeatIteration = 0; repeatIteration < maxRepeatCount; repeatIteration++) {
|
||||
// In each iteration, use a double from [0, 1] as the random roll
|
||||
auto sequence =
|
||||
std::vector{RandomnessSampleForRepeat(repeatIteration, sampleCount)};
|
||||
auto evaluation = EvaluateWithRandomness(
|
||||
auto sequence = std::vector{
|
||||
static_cast<double>(repeatIteration) /
|
||||
static_cast<double>(maxRepeatCount - 1)};
|
||||
auto [immediateScore, lookaheadScore] = EvaluateWithRandomness(
|
||||
pid,
|
||||
isDefender,
|
||||
originalIndex,
|
||||
@@ -433,12 +410,10 @@ auto AICommandEvaluator::FindBestCommand(
|
||||
allCastleCoords,
|
||||
deadline);
|
||||
|
||||
if (!evaluation.completed) { commandEvaluations[index].immediateCompleted = false; }
|
||||
sum += evaluation.immediateScore;
|
||||
commandEvaluations[index].lookaheadFutures.push_back(
|
||||
std::move(evaluation.lookaheadScore));
|
||||
sum += immediateScore;
|
||||
commandEvaluations[index].lookaheadFutures.push_back(std::move(lookaheadScore));
|
||||
}
|
||||
commandEvaluations[index].immediateScore = sum / sampleCount;
|
||||
commandEvaluations[index].immediateScore = sum / maxRepeatCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,33 +427,19 @@ auto AICommandEvaluator::FindBestCommand(
|
||||
// Wait for all futures and compute final scores
|
||||
for (auto& eval : evals) {
|
||||
ScoreValue totalLookaheadScore = 0.0;
|
||||
bool completed = eval.immediateCompleted;
|
||||
for (auto& future : eval.lookaheadFutures) {
|
||||
const auto lookahead = future.get();
|
||||
if (!lookahead.completed) { completed = false; }
|
||||
totalLookaheadScore += lookahead.score;
|
||||
totalLookaheadScore += future.get();
|
||||
}
|
||||
ScoreValue avgLookaheadScore =
|
||||
eval.lookaheadFutures.empty()
|
||||
? eval.immediateScore
|
||||
: totalLookaheadScore / eval.lookaheadFutures.size();
|
||||
|
||||
if (!completed) { continue; }
|
||||
|
||||
allResults.push_back(IndexAndScore{
|
||||
.index = eval.index,
|
||||
.type = eval.type,
|
||||
.lookaheadScore = avgLookaheadScore,
|
||||
.immediateScore = eval.immediateScore,
|
||||
.completed = true});
|
||||
}
|
||||
if (allResults.empty()) {
|
||||
return IndexAndScore{
|
||||
.index = 0,
|
||||
.type = net::eagle0::shardok::common::UNKNOWN_COMMAND,
|
||||
.lookaheadScore = 0.0,
|
||||
.immediateScore = 0.0,
|
||||
.completed = false};
|
||||
.immediateScore = eval.immediateScore});
|
||||
}
|
||||
// Find the best command using the existing sorter
|
||||
auto bestIt = std::ranges::max_element(allResults, CommandSorter);
|
||||
@@ -496,12 +457,12 @@ auto AICommandEvaluator::EvaluateCommand(
|
||||
const ScoreValue currentUtility,
|
||||
const CoordsSet& allCastleCoords,
|
||||
const size_t commandIndex,
|
||||
std::chrono::steady_clock::time_point deadline) const -> std::future<EvaluationResult> {
|
||||
std::chrono::steady_clock::time_point deadline) const -> std::future<ScoreValue> {
|
||||
const CommandListSPtr guessedDescriptors = guessedEngine.GetAvailableCommandsForAIPlayer(pid);
|
||||
|
||||
if (commandIndex >= guessedDescriptors->size()) {
|
||||
std::promise<EvaluationResult> p;
|
||||
p.set_value(EvaluationResult{.score = currentUtility, .completed = true});
|
||||
std::promise<ScoreValue> p;
|
||||
p.set_value(currentUtility);
|
||||
return p.get_future();
|
||||
}
|
||||
|
||||
@@ -509,11 +470,11 @@ auto AICommandEvaluator::EvaluateCommand(
|
||||
|
||||
if (const auto guessedCommandType = guessedDescriptor->GetCommandType();
|
||||
guessedCommandType == net::eagle0::shardok::common::END_TURN_COMMAND) {
|
||||
std::promise<EvaluationResult> p;
|
||||
p.set_value(EvaluationResult{.score = currentUtility, .completed = true});
|
||||
std::promise<ScoreValue> p;
|
||||
p.set_value(currentUtility);
|
||||
return p.get_future();
|
||||
} else if (IsDeterministic(guessedCommandType)) {
|
||||
auto evaluation = EvaluateWithRandomness(
|
||||
auto [immediateScore, lookaheadScore] = EvaluateWithRandomness(
|
||||
pid,
|
||||
isDefender,
|
||||
commandIndex,
|
||||
@@ -524,13 +485,13 @@ auto AICommandEvaluator::EvaluateCommand(
|
||||
attackerStrategy,
|
||||
allCastleCoords,
|
||||
deadline);
|
||||
return std::move(evaluation.lookaheadScore);
|
||||
return std::move(lookaheadScore);
|
||||
} else if (guessedDescriptor->HasOdds()) {
|
||||
const auto successChancePercentile = guessedDescriptor->GetOddsPercentile();
|
||||
const double successChance = static_cast<double>(successChancePercentile) / 100.0;
|
||||
|
||||
// Success attempt
|
||||
auto successEvaluation = EvaluateWithRandomness(
|
||||
auto [successImmediateScore, successLookaheadScore] = EvaluateWithRandomness(
|
||||
pid,
|
||||
isDefender,
|
||||
commandIndex,
|
||||
@@ -543,7 +504,7 @@ auto AICommandEvaluator::EvaluateCommand(
|
||||
deadline);
|
||||
|
||||
// Failure attempt
|
||||
auto failureEvaluation = EvaluateWithRandomness(
|
||||
auto [failureImmediateScore, failureLookaheadScore] = EvaluateWithRandomness(
|
||||
pid,
|
||||
isDefender,
|
||||
commandIndex,
|
||||
@@ -556,29 +517,20 @@ auto AICommandEvaluator::EvaluateCommand(
|
||||
deadline);
|
||||
|
||||
// Return weighted average of success and failure
|
||||
auto successSF = successEvaluation.lookaheadScore.share();
|
||||
auto failureSF = failureEvaluation.lookaheadScore.share();
|
||||
return std::async(
|
||||
std::launch::deferred,
|
||||
[successSF, failureSF, successChance]() -> EvaluationResult {
|
||||
const auto failure = failureSF.get();
|
||||
const auto success = successSF.get();
|
||||
if (!failure.completed || !success.completed) {
|
||||
return EvaluationResult{.score = 0.0, .completed = false};
|
||||
}
|
||||
return EvaluationResult{
|
||||
.score = std::lerp(failure.score, success.score, successChance),
|
||||
.completed = true};
|
||||
});
|
||||
auto successSF = successLookaheadScore.share();
|
||||
auto failureSF = failureLookaheadScore.share();
|
||||
return std::async(std::launch::deferred, [successSF, failureSF, successChance]() -> double {
|
||||
return std::lerp(failureSF.get(), successSF.get(), successChance);
|
||||
});
|
||||
} else {
|
||||
// For non-deterministic commands without odds, use multiple attempts
|
||||
std::vector<std::future<EvaluationResult>> lookaheadFutures;
|
||||
const int sampleCount = std::max(1, maxRepeatCount);
|
||||
lookaheadFutures.reserve(sampleCount);
|
||||
std::vector<std::future<ScoreValue>> lookaheadFutures;
|
||||
lookaheadFutures.reserve(maxRepeatCount);
|
||||
|
||||
for (int repeatIteration = 0; repeatIteration < sampleCount; repeatIteration++) {
|
||||
auto sequence = std::vector{RandomnessSampleForRepeat(repeatIteration, sampleCount)};
|
||||
auto evaluation = EvaluateWithRandomness(
|
||||
for (int repeatIteration = 0; repeatIteration < maxRepeatCount; repeatIteration++) {
|
||||
auto sequence = std::vector{
|
||||
static_cast<double>(repeatIteration) / static_cast<double>(maxRepeatCount - 1)};
|
||||
auto [immediateScore, lookaheadScore] = EvaluateWithRandomness(
|
||||
pid,
|
||||
isDefender,
|
||||
commandIndex,
|
||||
@@ -590,25 +542,17 @@ auto AICommandEvaluator::EvaluateCommand(
|
||||
allCastleCoords,
|
||||
deadline);
|
||||
|
||||
lookaheadFutures.push_back(std::move(evaluation.lookaheadScore));
|
||||
lookaheadFutures.push_back(std::move(lookaheadScore));
|
||||
}
|
||||
|
||||
// Return a future that computes the average when needed
|
||||
return std::async(
|
||||
std::launch::deferred,
|
||||
[lookaheadFutures = std::move(lookaheadFutures),
|
||||
sampleCount]() mutable -> EvaluationResult {
|
||||
maxRepeatCount]() mutable -> double {
|
||||
ScoreValue total = 0.0;
|
||||
bool completed = true;
|
||||
for (auto& future : lookaheadFutures) {
|
||||
const auto result = future.get();
|
||||
if (!result.completed) { completed = false; }
|
||||
total += result.score;
|
||||
}
|
||||
if (!completed) { return EvaluationResult{.score = 0.0, .completed = false}; }
|
||||
return EvaluationResult{
|
||||
.score = total / static_cast<ScoreValue>(sampleCount),
|
||||
.completed = true};
|
||||
for (auto& future : lookaheadFutures) { total += future.get(); }
|
||||
return total / maxRepeatCount;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,11 +38,6 @@ public:
|
||||
BattalionTypeGetter battalionTypeGetter); // Pass by value
|
||||
|
||||
/// Evaluates the score for a particular command index with lookahead.
|
||||
struct EvaluationResult {
|
||||
ScoreValue score;
|
||||
bool completed;
|
||||
};
|
||||
|
||||
[[nodiscard]] auto EvaluateCommand(
|
||||
PlayerId pid,
|
||||
bool isDefender,
|
||||
@@ -53,7 +48,7 @@ public:
|
||||
ScoreValue currentUtility,
|
||||
const CoordsSet& allCastleCoords,
|
||||
size_t commandIndex,
|
||||
std::chrono::steady_clock::time_point deadline) const -> std::future<EvaluationResult>;
|
||||
std::chrono::steady_clock::time_point deadline) const -> std::future<ScoreValue>;
|
||||
|
||||
/// Find the best command among all available commands at the given depth.
|
||||
struct IndexAndScore {
|
||||
@@ -61,7 +56,6 @@ public:
|
||||
CommandType type;
|
||||
ScoreValue lookaheadScore;
|
||||
ScoreValue immediateScore;
|
||||
bool completed;
|
||||
};
|
||||
|
||||
[[nodiscard]] auto FindBestCommand(
|
||||
@@ -82,8 +76,7 @@ private:
|
||||
|
||||
struct ImmediateAndLookaheadScore {
|
||||
ScoreValue immediateScore;
|
||||
bool completed;
|
||||
std::future<EvaluationResult> lookaheadScore;
|
||||
std::future<ScoreValue> lookaheadScore;
|
||||
};
|
||||
|
||||
/// Recursive lookahead calculator
|
||||
@@ -96,7 +89,7 @@ private:
|
||||
ScoreValue currentUtility,
|
||||
const AIStrategy& attackerStrategy,
|
||||
const CoordsSet& allCastleCoords,
|
||||
std::chrono::steady_clock::time_point deadline) const -> std::future<EvaluationResult>;
|
||||
std::chrono::steady_clock::time_point deadline) const -> std::future<ScoreValue>;
|
||||
|
||||
/// Evaluate single command execution with randomness handling
|
||||
[[nodiscard]] auto EvaluateWithRandomness(
|
||||
|
||||
@@ -75,47 +75,15 @@ std::vector<size_t> AICommandFilter::FilterCommands(
|
||||
// Calculate minimum distance to enemies once for all filters
|
||||
const double minDistToEnemies = MinDistanceToEnemyUnits(gameState, pid, enemyLocations);
|
||||
|
||||
bool hasAvailablePlacementCommand = false;
|
||||
for (const auto& cmd : *commands) {
|
||||
if (cmd->GetCommandType() == CommandType::PLACE_UNIT_COMMAND ||
|
||||
cmd->GetCommandType() == CommandType::PLACE_HIDDEN_UNIT_COMMAND) {
|
||||
hasAvailablePlacementCommand = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool playerHasReserveUnits = false;
|
||||
for (const Unit* unit : *gameState->units()) {
|
||||
if (unit->player_id() == pid &&
|
||||
unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT) {
|
||||
playerHasReserveUnits = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < commands->size(); ++i) {
|
||||
const auto& cmd = (*commands)[i];
|
||||
|
||||
if (cmd->GetCommandType() == CommandType::END_PLAYER_SETUP_COMMAND &&
|
||||
hasAvailablePlacementCommand && playerHasReserveUnits) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Always allow END_TURN commands
|
||||
if (cmd->GetCommandType() == CommandType::END_TURN_COMMAND) {
|
||||
filteredIndices.push_back(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Setup placement commands should always remain available. The movement/spell heuristics
|
||||
// below are combat-oriented and can otherwise filter every placement for an in-reserve
|
||||
// unit, making MCTS think ending setup is the only good option.
|
||||
if (cmd->GetCommandType() == CommandType::PLACE_UNIT_COMMAND ||
|
||||
cmd->GetCommandType() == CommandType::PLACE_HIDDEN_UNIT_COMMAND) {
|
||||
filteredIndices.push_back(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter obviously bad moves
|
||||
bool shouldFilter = false;
|
||||
|
||||
@@ -700,4 +668,4 @@ bool AICommandFilter::WouldAbandonCriticalCastle(
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
} // namespace shardok
|
||||
@@ -15,11 +15,11 @@ enum class AIAlgorithmType {
|
||||
|
||||
// Enum for scoring calculator selection
|
||||
enum class ScoringCalculatorType {
|
||||
STANDARD, // Default: Unbounded raw scores
|
||||
MCTS_OPTIMIZED, // Bounded linear scores tuned for MCTS
|
||||
EXPERIMENTAL // Standard-derived scorer selected by experiment id
|
||||
STANDARD, // Default: Unbounded raw scores
|
||||
NORMALIZED, // Normalized scores in [0, 1] range for ML training
|
||||
MCTS_OPTIMIZED // Bounded linear scores tuned for MCTS
|
||||
};
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_AI_CONFIG_HPP
|
||||
#endif // EAGLE0_AI_CONFIG_HPP
|
||||
@@ -84,7 +84,6 @@ auto AIDefenderStrategySelector::BestDefenderStrategy(
|
||||
// * the defender has way, way fewer troops
|
||||
// other considerations we should have but do not presently:
|
||||
// * the attacker is close to the defender / castles
|
||||
|
||||
if (canFlee && roundsRemaining > 2 &&
|
||||
defenderTroops < MAXIMUM_RATIO_FOR_DEFENDER_TO_FLEE * attackerTroops &&
|
||||
attackerNonUndeadUnitNotRequiringWaterCrossingCount >= criticalTileCoords.size()) {
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
//
|
||||
// Temporary AI experiment switches used by benchmark-only EXPERIMENTAL runs.
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_AI_EXPERIMENT_CONFIG_HPP
|
||||
#define EAGLE0_AI_EXPERIMENT_CONFIG_HPP
|
||||
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
|
||||
namespace shardok {
|
||||
|
||||
inline auto CurrentAIExperimentId() -> int {
|
||||
const char* rawValue = std::getenv("SHARDOK_SCORING_EXPERIMENT_ID");
|
||||
if (rawValue == nullptr || std::string(rawValue).empty()) { return 0; }
|
||||
return std::atoi(rawValue);
|
||||
}
|
||||
|
||||
inline auto IsAIExperiment(const int id) -> bool { return CurrentAIExperimentId() == id; }
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_AI_EXPERIMENT_CONFIG_HPP
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "AIUnitScoreCalculator.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
|
||||
#include "AIAttackLocations.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
|
||||
@@ -32,9 +33,7 @@ constexpr double kMeteorCastRoundMultiplier = 1.5;
|
||||
constexpr double kMeteorCastInsufficientVigorMultiplier = 0.5;
|
||||
constexpr double kMeteorCastleMultiplier = 2.0;
|
||||
constexpr double kLightningPossibleValue = 0.05;
|
||||
constexpr double kExpectedArcheryVolleyTargetValueFraction = 0.16;
|
||||
constexpr double kImmediateArcheryThreatMultiplier = 0.5;
|
||||
constexpr double kFutureArcheryThreatMultiplier = 0.25;
|
||||
constexpr double kArcheryPossibleValue = 38;
|
||||
constexpr double kReduceFortifiedPossibleValue = 50;
|
||||
constexpr double kReduceUnfortifiedPossibleValue = 12;
|
||||
constexpr double kFearPossibleValue = 38;
|
||||
@@ -102,24 +101,9 @@ auto ContextFreeUnitValue(const Unit *unit) -> ScoreValue {
|
||||
return battalionValue + heroValue;
|
||||
}
|
||||
|
||||
auto bestEnemyUnitValue(const vector<const Unit *> &enemyUnits) -> double {
|
||||
double bestTargetValue = 0.0;
|
||||
for (const Unit *enemyUnit : enemyUnits) {
|
||||
bestTargetValue = std::max(bestTargetValue, ContextFreeUnitValue(enemyUnit));
|
||||
}
|
||||
return bestTargetValue;
|
||||
}
|
||||
|
||||
auto expectedImpactArcheryValue(const vector<const Unit *> &enemyUnits, const bool canShootNow)
|
||||
-> double {
|
||||
const double expectedVolleyImpact =
|
||||
kExpectedArcheryVolleyTargetValueFraction * bestEnemyUnitValue(enemyUnits);
|
||||
return expectedVolleyImpact *
|
||||
(canShootNow ? kImmediateArcheryThreatMultiplier : kFutureArcheryThreatMultiplier);
|
||||
}
|
||||
|
||||
auto archeryValue(const vector<const Unit *> &enemyUnits, const bool canShootNow) -> double {
|
||||
return expectedImpactArcheryValue(enemyUnits, canShootNow);
|
||||
auto archeryValue(const Unit * /*unit*/) -> double {
|
||||
// TODO: make this depend on the value of the targets
|
||||
return kArcheryPossibleValue;
|
||||
}
|
||||
|
||||
auto reduceValue(const Unit *unit, const Terrain *unitTerrain) -> double {
|
||||
@@ -132,7 +116,10 @@ auto reduceValue(const Unit *unit, const Terrain *unitTerrain) -> double {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
auto fearValue(const vector<const Unit *> &) -> double { return kFearPossibleValue; }
|
||||
auto fearValue(const Unit * /*unit*/) -> double {
|
||||
// TODO: make this depend on the value of the targets
|
||||
return kFearPossibleValue;
|
||||
}
|
||||
|
||||
auto lightningValue(const Unit *unit) -> double {
|
||||
// TODO: make this depend on the value of the targets
|
||||
@@ -278,13 +265,11 @@ auto GetRangedAttackBonus(
|
||||
const vector<const Unit *> &attackerUnits,
|
||||
const vector<const Unit *> &defenderUnits,
|
||||
const int meteorRange,
|
||||
const double minVigorToCast,
|
||||
const ActionPoints archeryActionPointCost) -> double {
|
||||
const double minVigorToCast) -> double {
|
||||
vector<double> rangedAttackValues{};
|
||||
|
||||
const auto &enemyUnits = isAttacker ? defenderUnits : attackerUnits;
|
||||
const auto &friendlyUnits = isAttacker ? attackerUnits : defenderUnits;
|
||||
const bool canShootNow = unit->remaining_action_points() >= archeryActionPointCost;
|
||||
|
||||
const auto &unitLocation = unit->location();
|
||||
|
||||
@@ -321,19 +306,19 @@ auto GetRangedAttackBonus(
|
||||
unit->attached_hero().profession_info().profession() ==
|
||||
net::eagle0::shardok::storage::fb::Profession_NECROMANCER &&
|
||||
unit->attached_hero().control_info().controlled_unit_id() != -1) {
|
||||
rangedAttackValues.push_back(fearValue(enemyUnits));
|
||||
rangedAttackValues.push_back(fearValue(unit));
|
||||
}
|
||||
|
||||
if (attackLocations.ArcheryLocations().Contains(unitLocation) &&
|
||||
unit->volleys_remaining() > 0) {
|
||||
rangedAttackValues.push_back(archeryValue(enemyUnits, canShootNow));
|
||||
rangedAttackValues.push_back(archeryValue(unit));
|
||||
}
|
||||
|
||||
// Longbowmen in castle can archer adjacent
|
||||
if (attackLocations.AdjacentLocations().Contains(unitLocation) &&
|
||||
unit->volleys_remaining() > 0 && terrain->modifier().castle().present() &&
|
||||
unit->battalion().type() == net::eagle0::shardok::storage::fb::BattalionTypeId_LONGBOWMEN) {
|
||||
rangedAttackValues.push_back(archeryValue(enemyUnits, canShootNow));
|
||||
rangedAttackValues.push_back(archeryValue(unit));
|
||||
}
|
||||
|
||||
if (rangedAttackValues.empty()) return 0.0;
|
||||
@@ -353,8 +338,7 @@ auto UnitValue(
|
||||
const CoordsSet &locationsInDangerFromEnemy,
|
||||
const ActionPointDistances *distances,
|
||||
int meteorRange,
|
||||
double meteorCastVigorCost,
|
||||
ActionPoints archeryActionPointCost) -> ScoreValue {
|
||||
double meteorCastVigorCost) -> ScoreValue {
|
||||
const auto &location = unit->location();
|
||||
if (location.row() < 0) return 0; // unplaced unit
|
||||
|
||||
@@ -398,8 +382,7 @@ auto UnitValue(
|
||||
attackerUnits,
|
||||
defenderUnits,
|
||||
meteorRange,
|
||||
meteorCastVigorCost,
|
||||
archeryActionPointCost);
|
||||
meteorCastVigorCost);
|
||||
|
||||
// scouting values
|
||||
// attack range
|
||||
|
||||
@@ -32,8 +32,7 @@ auto GetRangedAttackBonus(
|
||||
const vector<const Unit *> &attackerUnits,
|
||||
const vector<const Unit *> &defenderUnits,
|
||||
int meteorRange,
|
||||
double minVigorToCast,
|
||||
ActionPoints archeryActionPointCost = 0) -> double;
|
||||
double minVigorToCast) -> double;
|
||||
|
||||
auto UnitValue(
|
||||
const Unit *unit,
|
||||
@@ -48,8 +47,7 @@ auto UnitValue(
|
||||
const CoordsSet &locationsInDangerFromEnemy,
|
||||
const ActionPointDistances *distances,
|
||||
int meteorRange,
|
||||
double meteorCastVigorCost,
|
||||
ActionPoints archeryActionPointCost = 0) -> ScoreValue;
|
||||
double meteorCastVigorCost) -> ScoreValue;
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
|
||||
@@ -156,9 +156,7 @@ The system uses recursive lookahead with:
|
||||
- `kProfessionValue`: 200
|
||||
|
||||
### Ranged Attack Values
|
||||
- Archery position value: expected-impact based. A unit that can shoot now gets half of
|
||||
`0.16 * best target unit value`; a unit in archery range that cannot shoot yet gets one
|
||||
quarter of that expected volley value.
|
||||
- `kArcheryPossibleValue`: 38
|
||||
- `kMeteorDirectTargetingEnemy`: 2 per soldier
|
||||
- `kMeteorSplashTargetingEnemy`: 1 per soldier
|
||||
- `kLightningPossibleValue`: 0.05 per soldier
|
||||
@@ -764,4 +762,4 @@ MCTSAI ai(playerId, isDefender, strategy, castleCoords, apdCache, alCache, confi
|
||||
- Higher `immediateScoreTieBreakThreshold` to emphasize direct paths
|
||||
- `BEST_IMMEDIATE` simulation for most predictable behavior
|
||||
|
||||
The configuration system allows fine-tuning MCTS behavior for different scenarios while maintaining compatibility with the existing AI infrastructure.
|
||||
The configuration system allows fine-tuning MCTS behavior for different scenarios while maintaining compatibility with the existing AI infrastructure.
|
||||
@@ -11,13 +11,6 @@ cc_library(
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "ai_experiment_config",
|
||||
hdrs = ["AIExperimentConfig.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = ["//src/main/cpp/net/eagle0/shardok/ai:__subpackages__"],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "ai_attacker_strategy_selector",
|
||||
srcs = ["AIAttackerStrategySelector.cpp"],
|
||||
@@ -91,7 +84,6 @@ cc_library(
|
||||
],
|
||||
deps = [
|
||||
":ai_attack_locations",
|
||||
":ai_experiment_config",
|
||||
":ai_score_utilities",
|
||||
":ai_strategy",
|
||||
":ai_water_crossing_calculator",
|
||||
@@ -204,7 +196,6 @@ cc_library(
|
||||
],
|
||||
deps = [
|
||||
":ai_command_filter",
|
||||
":ai_experiment_config",
|
||||
":ai_strategy",
|
||||
":transposition_table",
|
||||
"//src/main/cpp/net/eagle0/common:sequence_random_generator",
|
||||
@@ -281,7 +272,6 @@ cc_library(
|
||||
],
|
||||
deps = [
|
||||
":ai_attack_locations",
|
||||
":ai_experiment_config",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
|
||||
],
|
||||
@@ -358,7 +348,6 @@ cc_library(
|
||||
":ai_attacker_strategy_selector",
|
||||
":ai_command_evaluator",
|
||||
":ai_defender_strategy_selector",
|
||||
":ai_experiment_config",
|
||||
":ai_time_budget",
|
||||
":ai_water_crossing_command_chooser",
|
||||
"//src/main/cpp/net/eagle0/common:time_utils",
|
||||
@@ -396,6 +385,7 @@ cc_library(
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts:shardok_mcts_ai", # MCTS with abstraction layer
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:mcts_optimized_ai_score_calculator", # Bounded linear scorer for MCTS
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:normalized_ai_score_calculator", # Normalized [0,1] scorer for ML training
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score:standard_ai_score_calculator", # Standard unbounded scorer (default)
|
||||
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
#include "AICommandEvaluator.hpp"
|
||||
#include "AICommandFilter.hpp"
|
||||
#include "TranspositionTable.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIExperimentConfig.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
|
||||
|
||||
@@ -131,10 +130,6 @@ auto IterativeDeepeningAI::IterativeSearch(
|
||||
// Now wait for all futures and collect results
|
||||
for (auto& [cmdIndex, future] : futures) {
|
||||
auto cmdResult = future.get();
|
||||
if (!cmdResult.searchCompleted) {
|
||||
allEvaluated = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure scoresByDepth[cmdIndex] has enough space
|
||||
if (scoresByDepth[cmdIndex].size() <= currentDepth) {
|
||||
@@ -226,7 +221,7 @@ auto IterativeDeepeningAI::IterativeSearch(
|
||||
}
|
||||
|
||||
// If all evaluated commands had unchanged scores, we've hit END_TURN in lookahead
|
||||
if (!IsAIExperiment(26) && scoresUnchanged && unchangedCount == evaluatedCount) {
|
||||
if (scoresUnchanged && unchangedCount == evaluatedCount) {
|
||||
completionReason = EvaluationCompletionReason::RAN_OUT_OF_COMMANDS;
|
||||
break;
|
||||
}
|
||||
@@ -256,9 +251,7 @@ auto IterativeDeepeningAI::IterativeSearch(
|
||||
completionReason = EvaluationCompletionReason::RAN_OUT_OF_COMMANDS;
|
||||
}
|
||||
|
||||
// Select best result from the highest completed depth achieved for each command. Commands that
|
||||
// timed out at a depth are not recorded for that depth, so partial deeper work can help without
|
||||
// letting timeout fallback scores compete.
|
||||
// Select best result from highest depth achieved for each command
|
||||
result = SelectBestResult(scoresByDepth, highestDepthCompleted);
|
||||
result.minimumDepthCompleted = result.depthAchieved >= timeBudget.minDepthRequired;
|
||||
result.searchCompleted = result.minimumDepthCompleted;
|
||||
@@ -349,9 +342,7 @@ auto IterativeDeepeningAI::SearchCommandAtDepthWithEngine(
|
||||
// Deduct adjusted time from remaining budget
|
||||
timeBudget.remainingBudget -= adjustedElapsedMs;
|
||||
|
||||
result.bestScore = commandScore.score;
|
||||
result.searchCompleted = commandScore.completed;
|
||||
result.minimumDepthCompleted = commandScore.completed;
|
||||
result.bestScore = commandScore;
|
||||
|
||||
std::promise<SearchResult> p;
|
||||
p.set_value(result);
|
||||
@@ -364,7 +355,7 @@ auto IterativeDeepeningAI::GetCommandsSortedByPreviousDepth(
|
||||
const std::vector<size_t>& highestDepthCompleted,
|
||||
const std::vector<size_t>& filteredIndices) -> std::vector<size_t> {
|
||||
if (currentDepth == 1) {
|
||||
// For depth 1, preserve the upstream command-factory priority order.
|
||||
// For depth 1, return filtered indices in natural order
|
||||
return filteredIndices;
|
||||
}
|
||||
|
||||
@@ -401,20 +392,10 @@ auto IterativeDeepeningAI::SelectBestResult(
|
||||
result.bestScore = -std::numeric_limits<ScoreValue>::infinity();
|
||||
result.searchCompleted = false;
|
||||
|
||||
size_t selectionDepth = 0;
|
||||
if (IsAIExperiment(24)) {
|
||||
selectionDepth = std::numeric_limits<size_t>::max();
|
||||
for (const size_t depth : highestDepthCompleted) {
|
||||
if (depth > 0) { selectionDepth = std::min(selectionDepth, depth); }
|
||||
}
|
||||
if (selectionDepth == std::numeric_limits<size_t>::max()) { selectionDepth = 0; }
|
||||
}
|
||||
|
||||
// Find the command with best score at its highest evaluated depth
|
||||
for (size_t i = 0; i < scoresByDepth.size(); ++i) {
|
||||
if (highestDepthCompleted[i] > 0) {
|
||||
const size_t depth = selectionDepth > 0 ? selectionDepth : highestDepthCompleted[i];
|
||||
if (highestDepthCompleted[i] < depth || scoresByDepth[i].size() <= depth) { continue; }
|
||||
const size_t depth = highestDepthCompleted[i];
|
||||
if (ScoreValue score = scoresByDepth[i][depth]; score > result.bestScore) {
|
||||
result.bestScore = score;
|
||||
result.bestCommandIndex = i;
|
||||
@@ -426,4 +407,4 @@ auto IterativeDeepeningAI::SelectBestResult(
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
} // namespace shardok
|
||||
@@ -111,4 +111,4 @@ private:
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_ITERATIVEDEEPENINGAI_HPP
|
||||
#endif // EAGLE0_ITERATIVEDEEPENINGAI_HPP
|
||||
@@ -32,6 +32,7 @@
|
||||
#include "IterativeDeepeningAI.hpp"
|
||||
#include "mcts/ShardokMCTSAI.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/MCTSOptimizedAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/NormalizedAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/StandardAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/view_filters/GameStateGuesser.hpp"
|
||||
@@ -298,12 +299,12 @@ auto ShardokAIClient::StandardChooseCommandIndex(
|
||||
// Create scorer for actual scoring during search - type selected at construction
|
||||
std::unique_ptr<AIScoreCalculator> scorer;
|
||||
switch (scoringCalculatorType) {
|
||||
case ScoringCalculatorType::NORMALIZED:
|
||||
scorer = MakeNormalizedAIScoreCalculator(settingsGetter, apdCache, alCache);
|
||||
break;
|
||||
case ScoringCalculatorType::MCTS_OPTIMIZED:
|
||||
scorer = MakeMCTSOptimizedAIScoreCalculator(settingsGetter, apdCache, alCache);
|
||||
break;
|
||||
case ScoringCalculatorType::EXPERIMENTAL:
|
||||
scorer = MakeExperimentalAIScoreCalculator(settingsGetter, apdCache, alCache);
|
||||
break;
|
||||
case ScoringCalculatorType::STANDARD:
|
||||
default: scorer = MakeStandardAIScoreCalculator(settingsGetter, apdCache, alCache); break;
|
||||
}
|
||||
|
||||
@@ -21,22 +21,6 @@
|
||||
|
||||
namespace shardok::mcts {
|
||||
|
||||
namespace {
|
||||
|
||||
auto IsDefenderPlayer(const GameStateW& gameState, const PlayerId playerId) -> bool {
|
||||
for (const auto* pi : *gameState->player_infos()) {
|
||||
if (pi->player_id() == playerId) { return pi->is_defender(); }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
auto LegalActionsCacheKey(const uint64_t stateHash, const bool rootIsDefender) -> uint64_t {
|
||||
constexpr uint64_t kDefenderRoleHash = 0x9e3779b97f4a7c15ULL;
|
||||
return rootIsDefender ? stateHash ^ kDefenderRoleHash : stateHash;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Shared cache for legal actions (uses lock-free parallel hash map for thread safety)
|
||||
// Using 8 submaps to reduce contention with 16 MCTS threads
|
||||
gtl::parallel_flat_hash_map<
|
||||
@@ -239,14 +223,13 @@ std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
|
||||
// Time hash computation
|
||||
const auto hashStart = std::chrono::high_resolution_clock::now();
|
||||
const uint64_t stateHash = shardokState->hash();
|
||||
const uint64_t cacheKey = LegalActionsCacheKey(stateHash, isDefender_);
|
||||
const auto hashEnd = std::chrono::high_resolution_clock::now();
|
||||
timeInHashComputation_.fetch_add(
|
||||
std::chrono::duration_cast<std::chrono::microseconds>(hashEnd - hashStart).count(),
|
||||
std::memory_order_relaxed);
|
||||
|
||||
// Check transposition table for cached legal actions
|
||||
if (auto it = legalActionsCache_.find(cacheKey); it != legalActionsCache_.end()) {
|
||||
if (auto it = legalActionsCache_.find(stateHash); it != legalActionsCache_.end()) {
|
||||
cacheHits_.fetch_add(1, std::memory_order_relaxed);
|
||||
|
||||
// Use cached engine
|
||||
@@ -321,7 +304,7 @@ std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
|
||||
const std::vector<size_t> filteredIndices = AICommandFilter::FilterCommands(
|
||||
commands,
|
||||
currentPlayer,
|
||||
IsDefenderPlayer(shardokState->getShardokState(), currentPlayer),
|
||||
isDefender_,
|
||||
shardokState->getShardokState(),
|
||||
*apdCache_,
|
||||
[this](BattalionTypeId typeId) {
|
||||
@@ -379,7 +362,7 @@ std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
|
||||
// This avoids duplicating heavy protocol buffer objects
|
||||
// Use lazy_emplace_l to ensure thread-safe insertion (locks the bucket during construction)
|
||||
legalActionsCache_.lazy_emplace_l(
|
||||
cacheKey,
|
||||
stateHash,
|
||||
[&](typename decltype(legalActionsCache_)::value_type& v) {
|
||||
// Update existing entry
|
||||
v.second.filteredIndices = filteredIndices;
|
||||
@@ -387,7 +370,7 @@ std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
|
||||
},
|
||||
[&](const typename decltype(legalActionsCache_)::constructor& ctor) {
|
||||
// Create new entry
|
||||
ctor(cacheKey, LegalActionsCache{filteredIndices, engine});
|
||||
ctor(stateHash, LegalActionsCache{filteredIndices, engine});
|
||||
});
|
||||
|
||||
return actions;
|
||||
@@ -433,8 +416,14 @@ std::vector<double> ShardokGameEngine::getActionWeights(
|
||||
|
||||
// Determine if current player is defender (not root player!)
|
||||
// During simulation we need to use the correct perspective for action weighting
|
||||
bool currentPlayerIsDefender = false;
|
||||
const auto& gameState = shardokState->getShardokState();
|
||||
const bool currentPlayerIsDefender = IsDefenderPlayer(gameState, currentPlayer);
|
||||
for (const auto* pi : *gameState->player_infos()) {
|
||||
if (pi->player_id() == currentPlayer) {
|
||||
currentPlayerIsDefender = pi->is_defender();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Use AIHeuristicWeighting for fast O(1) context-aware command weighting
|
||||
std::vector<double> weights;
|
||||
|
||||
@@ -110,19 +110,8 @@ bool ShardokGameState::equals(const MCTSGameState& other) const {
|
||||
}
|
||||
|
||||
MCTSPlayerId ShardokGameState::getWinner() const {
|
||||
if (!state_->status() ||
|
||||
state_->status()->state() !=
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const auto* winningIds = state_->status()->winning_shardok_ids();
|
||||
if (!winningIds || winningIds->empty()) { return -1; }
|
||||
|
||||
for (const PlayerId winningPid : *winningIds) {
|
||||
if (winningPid >= 0) { return winningPid; }
|
||||
}
|
||||
|
||||
// Note: FlatBuffer doesn't have a winner field
|
||||
// In practice, this would need to determine winner from victory conditions
|
||||
return -1; // No winner
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,31 @@ cc_library(
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "normalized_ai_score_calculator",
|
||||
srcs = ["NormalizedAIScoreCalculator.cpp"],
|
||||
hdrs = ["NormalizedAIScoreCalculator.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai_performance_runner:__pkg__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":ai_score_calculator_interface",
|
||||
":ai_victory_condition_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_strategy",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_unit_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_water_crossing_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score/private:abstract_ai_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/score/private:ai_score_calculator_shared_utilities",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:engine",
|
||||
"//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/settings:game_settings",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "standard_ai_score_calculator",
|
||||
srcs = ["StandardAIScoreCalculator.cpp"],
|
||||
@@ -54,7 +79,6 @@ cc_library(
|
||||
deps = [
|
||||
":ai_score_calculator_interface",
|
||||
":ai_victory_condition_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_experiment_config",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_strategy",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_unit_score_calculator",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:ai_water_crossing_calculator",
|
||||
|
||||
@@ -59,7 +59,6 @@ public:
|
||||
MCTSOptimizedAIScoreCalculator(
|
||||
int maxRounds,
|
||||
ActionPoints braveWaterCost,
|
||||
ActionPoints archeryActionPointCost,
|
||||
int meteorRange,
|
||||
double meteorCastVigorCost,
|
||||
int minimumFleeOddsThreshold,
|
||||
@@ -70,7 +69,6 @@ public:
|
||||
: AbstractAIScoreCalculator(
|
||||
maxRounds,
|
||||
braveWaterCost,
|
||||
archeryActionPointCost,
|
||||
meteorRange,
|
||||
meteorCastVigorCost,
|
||||
minimumFleeOddsThreshold,
|
||||
@@ -238,7 +236,6 @@ auto MakeMCTSOptimizedAIScoreCalculator(
|
||||
return std::make_unique<MCTSOptimizedAIScoreCalculator>(
|
||||
settingsGetter.Backing().max_rounds(),
|
||||
settingsGetter.Backing().brave_water_action_point_cost(),
|
||||
settingsGetter.Backing().archery_action_point_cost(),
|
||||
settingsGetter.Backing().meteor_range(),
|
||||
settingsGetter.Backing().meteor_cast_vigor_cost(),
|
||||
settingsGetter.Backing().ai_minimum_flee_odds_threshold(),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
We need a scoring algorithm that makes MCTS perform well by providing score differences in the right range:
|
||||
|
||||
- **Standard Scorer**: Returns unbounded relative scores. Small differences get amplified in MCTS UCB formula, causing over-exploitation (commits to 1-2 high-scoring nodes too early).
|
||||
- **Retired bounded-ratio scorer**: Returned scores in [0,1] range with power transformation (exponent=0.1). Differences were too compressed (~0.02-0.05), causing over-exploration (all nodes explored equally, AI made bad choices).
|
||||
- **Normalized Scorer**: Returns scores in [0,1] range with power transformation (exponent=0.1). Differences are too compressed (~0.02-0.05), causing over-exploration (all nodes explored equally, AI makes bad choices).
|
||||
|
||||
### MCTS Requirements
|
||||
|
||||
@@ -32,9 +32,9 @@ This means individual scores should differ by **0.5 to 2.0** between meaningfull
|
||||
- Range: 0 (all captured) to -3000 (all held, far away)
|
||||
|
||||
### Terminal States
|
||||
- Victory: INT_MAX
|
||||
- Defeat: INT_MIN
|
||||
- Flee/Draw: 0
|
||||
- Victory: INT_MAX (or 1.0 for normalized)
|
||||
- Defeat: INT_MIN (or 0.0 for normalized)
|
||||
- Flee/Draw: 0 (or 0.5 for normalized)
|
||||
- Captured unit: -10,000
|
||||
- Captured VIP: -25,000
|
||||
|
||||
@@ -257,7 +257,7 @@ This progression is ideal:
|
||||
|
||||
This avoids both pathologies:
|
||||
- Not over-exploiting (like Standard scorer which overcommitted to tiny early differences)
|
||||
- Not over-exploring like the retired bounded-ratio scorer, which explored equally even with large advantages
|
||||
- Not over-exploring (like Normalized scorer which explored equally even with large advantages)
|
||||
|
||||
## Why MCTS Needs Stronger Signal Than Minimax
|
||||
|
||||
@@ -281,11 +281,11 @@ This gives MCTS the right balance: explore when moves are similar, exploit when
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. **Use same calculation structure**: Inherit from AbstractAIScoreCalculator like Standard
|
||||
1. **Use same calculation structure**: Inherit from AbstractAIScoreCalculator like Standard and Normalized
|
||||
2. **Reuse unit scoring**: Use existing CalculateUnitsScoreComponents and victory condition calculators
|
||||
3. **Only change combination**: Override CombineAttackerScores, CombineDefenderScores, etc.
|
||||
4. **Bounded terminals**: Use ±1000 instead of INT_MAX/MIN for numerical stability
|
||||
5. **No transformation**: Do not apply power transformation - linear scaling is sufficient
|
||||
5. **No transformation**: Unlike Normalized, don't apply power transformation - linear scaling is sufficient
|
||||
6. **Scale constants tuned for MCTS**: 10x larger than naive normalization to provide appropriate signal strength
|
||||
|
||||
## Testing with Integration Tests
|
||||
@@ -383,5 +383,6 @@ Once the scorer passes integration tests with IterativeDeepeningAI, then integra
|
||||
|
||||
- `BoundedLinearAIScoreCalculator`
|
||||
- `MCTSOptimizedAIScoreCalculator`
|
||||
- `LinearNormalizedAIScoreCalculator`
|
||||
|
||||
Recommend: **`MCTSOptimizedAIScoreCalculator`** to clearly indicate purpose.
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
//
|
||||
// Normalized [0,1] implementation of AIScoreCalculator
|
||||
//
|
||||
|
||||
#include "NormalizedAIScoreCalculator.hpp"
|
||||
|
||||
#include <cmath>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "private/AIScoreCalculatorSharedUtilities.hpp"
|
||||
#include "private/AbstractAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreUtilities.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIUnitScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
using net::eagle0::shardok::storage::fb::BattalionTypeId;
|
||||
|
||||
// Bring shared utilities into scope
|
||||
using score_calculator_internal::UnitsScoreComponents;
|
||||
|
||||
/// Normalized implementation of AIScoreCalculator that produces scores in [0, 1] range.
|
||||
/// Inherits from AbstractAIScoreCalculator to share common functionality.
|
||||
class NormalizedAIScoreCalculator : public AbstractAIScoreCalculator {
|
||||
public:
|
||||
NormalizedAIScoreCalculator(
|
||||
int maxRounds,
|
||||
ActionPoints braveWaterCost,
|
||||
int meteorRange,
|
||||
double meteorCastVigorCost,
|
||||
int minimumFleeOddsThreshold,
|
||||
int desperateFleeThreshold,
|
||||
std::vector<BattalionTypeSPtr> battalionTypes,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache)
|
||||
: AbstractAIScoreCalculator(
|
||||
maxRounds,
|
||||
braveWaterCost,
|
||||
meteorRange,
|
||||
meteorCastVigorCost,
|
||||
minimumFleeOddsThreshold,
|
||||
desperateFleeThreshold,
|
||||
std::move(battalionTypes),
|
||||
apdCache,
|
||||
alCache) {}
|
||||
|
||||
[[nodiscard]] auto GuessedStateScore(
|
||||
bool isDefender,
|
||||
const GameStateW &state,
|
||||
const AIStrategy &aiStrategy,
|
||||
const CoordsSet &allCastleCoords) const -> ScoreValue override;
|
||||
|
||||
// Implement pure virtual methods from AbstractAIScoreCalculator
|
||||
[[nodiscard]] auto InterpretDefenderOutcome(GameOutcome outcome) const -> ScoreValue override;
|
||||
[[nodiscard]] auto InterpretAttackerOutcome(GameOutcome outcome) const -> ScoreValue override;
|
||||
|
||||
[[nodiscard]] auto AttackerFleeStrategyScoreForState(const GameStateW &gameState) const
|
||||
-> ScoreValue override;
|
||||
|
||||
[[nodiscard]] auto CombineAttackerScores(
|
||||
const UnitsScoreComponents &components,
|
||||
double victoryConditionScore,
|
||||
int roundsRemaining) const -> ScoreValue override;
|
||||
|
||||
[[nodiscard]] auto CombineDefenderScatterScores(const UnitsScoreComponents &components) const
|
||||
-> ScoreValue override;
|
||||
|
||||
[[nodiscard]] auto CombineDefenderHoldCastlesScores(
|
||||
const UnitsScoreComponents &components,
|
||||
double victoryConditionScore,
|
||||
int roundsRemaining) const -> ScoreValue override;
|
||||
|
||||
private:
|
||||
[[nodiscard]] auto DefenderFleeStrategyScoreForState(const GameStateW &gameState) const
|
||||
-> ScoreValue override;
|
||||
|
||||
/// Applies power transformation to spread out compressed scores for MCTS.
|
||||
/// Maps [0,1] → [0,1] but pushes values away from 0.5 toward the extremes.
|
||||
/// Terminal states (0.0, 1.0) are unchanged.
|
||||
[[nodiscard]] auto TransformForMCTS(ScoreValue score) const -> ScoreValue;
|
||||
};
|
||||
|
||||
// Implementation of NormalizedAIScoreCalculator methods
|
||||
|
||||
auto NormalizedAIScoreCalculator::InterpretDefenderOutcome(GameOutcome outcome) const
|
||||
-> ScoreValue {
|
||||
switch (outcome) {
|
||||
case GameOutcome::DEFENDER_VICTORY: return 1.0;
|
||||
case GameOutcome::ATTACKER_VICTORY: return 0.0;
|
||||
case GameOutcome::DRAW: return 0.5;
|
||||
case GameOutcome::FLEE_OUTCOME: return 0.5;
|
||||
}
|
||||
throw ShardokInternalErrorException("Unknown GameOutcome");
|
||||
}
|
||||
|
||||
auto NormalizedAIScoreCalculator::InterpretAttackerOutcome(GameOutcome outcome) const
|
||||
-> ScoreValue {
|
||||
switch (outcome) {
|
||||
case GameOutcome::ATTACKER_VICTORY: return 1.0;
|
||||
case GameOutcome::DEFENDER_VICTORY: return 0.0;
|
||||
case GameOutcome::DRAW: return 0.5;
|
||||
case GameOutcome::FLEE_OUTCOME: return 0.5;
|
||||
}
|
||||
throw ShardokInternalErrorException("Unknown GameOutcome");
|
||||
}
|
||||
|
||||
auto NormalizedAIScoreCalculator::CombineDefenderScatterScores(
|
||||
const UnitsScoreComponents &components) const -> ScoreValue {
|
||||
// For defender, we flip the perspective: defenderValue is (1), attackerValue is (2)
|
||||
const double defenderValue = components.defenderUnitsValue;
|
||||
const double attackerValue = components.attackerUnitsValue;
|
||||
|
||||
// No victory condition for scatter strategy
|
||||
const double denominator = defenderValue + attackerValue;
|
||||
if (denominator == 0.0) { return 0.5; }
|
||||
|
||||
return defenderValue / denominator;
|
||||
}
|
||||
|
||||
auto NormalizedAIScoreCalculator::CombineDefenderHoldCastlesScores(
|
||||
const UnitsScoreComponents &components,
|
||||
const double victoryConditionScore,
|
||||
const int /*roundsRemaining*/) const -> ScoreValue {
|
||||
// For defender, flip perspective
|
||||
const double defenderValue = components.defenderUnitsValue;
|
||||
const double attackerValue = components.attackerUnitsValue;
|
||||
|
||||
// Apply normalization
|
||||
double numerator;
|
||||
double denominator;
|
||||
|
||||
if (victoryConditionScore >= 0) {
|
||||
numerator = defenderValue + victoryConditionScore;
|
||||
denominator = defenderValue + attackerValue + victoryConditionScore;
|
||||
} else {
|
||||
numerator = defenderValue;
|
||||
denominator = defenderValue + attackerValue - victoryConditionScore;
|
||||
}
|
||||
|
||||
if (denominator == 0.0) { return 0.5; }
|
||||
|
||||
return numerator / denominator;
|
||||
}
|
||||
|
||||
auto NormalizedAIScoreCalculator::DefenderFleeStrategyScoreForState(
|
||||
const GameStateW & /*gameState*/) const -> ScoreValue {
|
||||
// FLEE strategy doesn't fit the [0,1] model well - return 0.5
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
auto NormalizedAIScoreCalculator::AttackerFleeStrategyScoreForState(
|
||||
const GameStateW & /*gameState*/) const -> ScoreValue {
|
||||
// FLEE strategy doesn't fit the [0,1] model well - return 0.5
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
auto NormalizedAIScoreCalculator::CombineAttackerScores(
|
||||
const UnitsScoreComponents &components,
|
||||
const double victoryConditionScore,
|
||||
const int /*roundsRemaining*/) const -> ScoreValue {
|
||||
const double attackerUnitsValue = components.attackerUnitsValue;
|
||||
const double defenderUnitsValue = components.defenderUnitsValue;
|
||||
|
||||
// Apply normalization formula
|
||||
double numerator;
|
||||
double denominator;
|
||||
|
||||
if (victoryConditionScore >= 0) {
|
||||
// Positive victory condition: add to numerator
|
||||
numerator = attackerUnitsValue + victoryConditionScore;
|
||||
denominator = attackerUnitsValue + defenderUnitsValue + victoryConditionScore;
|
||||
} else {
|
||||
// Negative victory condition: subtract from denominator (making it larger)
|
||||
numerator = attackerUnitsValue;
|
||||
denominator = attackerUnitsValue + defenderUnitsValue - victoryConditionScore;
|
||||
}
|
||||
|
||||
// Handle edge case of all zeros
|
||||
if (denominator == 0.0) { return 0.5; }
|
||||
|
||||
return numerator / denominator;
|
||||
}
|
||||
|
||||
auto NormalizedAIScoreCalculator::TransformForMCTS(ScoreValue score) const -> ScoreValue {
|
||||
// Power transformation exponent - lower values spread scores more toward extremes
|
||||
// Tuned for MCTS: balances exploration vs exploitation
|
||||
// - Too low (e.g., 0.3): over-exploitation like standard scorer
|
||||
// - Too high (e.g., 0.9): over-exploration like untransformed normalized
|
||||
// - 0.6-0.7: sweet spot for MCTS
|
||||
constexpr double EXPONENT = 0.1;
|
||||
|
||||
if (score > 0.5) {
|
||||
// Map [0.5, 1.0] → [0.5, 1.0] with power curve
|
||||
// (score - 0.5) * 2.0 maps to [0, 1], apply power, then scale back
|
||||
return 0.5 + 0.5 * std::pow((score - 0.5) * 2.0, EXPONENT);
|
||||
} else {
|
||||
// Map [0.0, 0.5] → [0.0, 0.5] with power curve (symmetric)
|
||||
return 0.5 - 0.5 * std::pow((0.5 - score) * 2.0, EXPONENT);
|
||||
}
|
||||
}
|
||||
|
||||
auto NormalizedAIScoreCalculator::GuessedStateScore(
|
||||
const bool isDefender,
|
||||
const GameStateW &state,
|
||||
const AIStrategy &aiStrategy,
|
||||
const CoordsSet &allCastleCoords) const -> ScoreValue {
|
||||
const int roundsRemaining = GetMaxRounds() - state->current_round();
|
||||
|
||||
ScoreValue rawScore;
|
||||
if (isDefender) {
|
||||
rawScore = DefenderScoreForState(state, aiStrategy, allCastleCoords, roundsRemaining);
|
||||
} else {
|
||||
rawScore = AttackerScoreForState(state, aiStrategy, allCastleCoords, roundsRemaining);
|
||||
}
|
||||
|
||||
// Apply power transformation to spread out scores for MCTS
|
||||
return TransformForMCTS(rawScore);
|
||||
}
|
||||
|
||||
// Factory function implementation
|
||||
auto MakeNormalizedAIScoreCalculator(
|
||||
const SettingsGetter &settingsGetter,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache) -> std::unique_ptr<AIScoreCalculator> {
|
||||
// Extract all battalion types into a vector indexed by BattalionTypeId
|
||||
std::vector<BattalionTypeSPtr> battalionTypes(BattalionTypeId::BattalionTypeId_MAX + 1);
|
||||
for (int typeId = BattalionTypeId::BattalionTypeId_MIN;
|
||||
typeId <= BattalionTypeId::BattalionTypeId_MAX;
|
||||
typeId++) {
|
||||
auto battalionTypeId = static_cast<BattalionTypeId>(typeId);
|
||||
battalionTypes[battalionTypeId] = settingsGetter.GetBattalionType(battalionTypeId);
|
||||
}
|
||||
|
||||
return std::make_unique<NormalizedAIScoreCalculator>(
|
||||
settingsGetter.Backing().max_rounds(),
|
||||
settingsGetter.Backing().brave_water_action_point_cost(),
|
||||
settingsGetter.Backing().meteor_range(),
|
||||
settingsGetter.Backing().meteor_cast_vigor_cost(),
|
||||
settingsGetter.Backing().ai_minimum_flee_odds_threshold(),
|
||||
settingsGetter.Backing().ai_desperate_flee_threshold(),
|
||||
std::move(battalionTypes),
|
||||
apdCache,
|
||||
alCache);
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// Normalized [0,1] implementation of AIScoreCalculator
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_NORMALIZEDAISCORECALCULATOR_HPP
|
||||
#define EAGLE0_NORMALIZEDAISCORECALCULATOR_HPP
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
// Forward declarations
|
||||
class AIScoreCalculator;
|
||||
|
||||
using APDCache = std::shared_ptr<ActionPointDistancesCache>;
|
||||
using ALCache = std::unique_ptr<AttackLocationsCache>;
|
||||
|
||||
/// Factory function to create a NormalizedAIScoreCalculator.
|
||||
/// Returns a unique_ptr to AIScoreCalculator to hide the implementation.
|
||||
///
|
||||
/// The normalized scorer produces scores in the range [0, 1] where:
|
||||
/// - 0.0 = complete defender victory
|
||||
/// - 1.0 = complete attacker victory
|
||||
/// - 0.5 = neutral/draw state
|
||||
///
|
||||
/// Terminal states (victory/defeat) always return 1.0 or 0.0.
|
||||
/// Non-terminal states use asymmetric normalization:
|
||||
/// - If victory condition >= 0:
|
||||
/// score = (attackerUnits + victoryCondition) / (attackerUnits + defenderUnits +
|
||||
/// victoryCondition)
|
||||
/// - If victory condition < 0:
|
||||
/// score = attackerUnits / (attackerUnits + defenderUnits - victoryCondition)
|
||||
[[nodiscard]] auto MakeNormalizedAIScoreCalculator(
|
||||
const SettingsGetter& settingsGetter,
|
||||
const APDCache& apdCache,
|
||||
const ALCache& alCache) -> std::unique_ptr<AIScoreCalculator>;
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_NORMALIZEDAISCORECALCULATOR_HPP
|
||||
@@ -4,17 +4,14 @@
|
||||
|
||||
#include "StandardAIScoreCalculator.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "private/AIScoreCalculatorSharedUtilities.hpp"
|
||||
#include "private/AbstractAIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackGroups.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIExperimentConfig.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreUtilities.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIUnitScoreCalculator.hpp"
|
||||
@@ -70,68 +67,6 @@ struct AttackerScorePerformanceLogger {
|
||||
}
|
||||
};
|
||||
|
||||
struct StandardScoringExperiment {
|
||||
double attackerUnitsScale = 1.0;
|
||||
double defenderUnitsScale = 1.0;
|
||||
double victoryConditionScale = 1.0;
|
||||
double unitsBaseMultiplierScale = 1.0;
|
||||
double attackerRoundMultiplier = 1.0;
|
||||
double defenderRoundAdvantageScale = 1.0;
|
||||
};
|
||||
|
||||
auto ExperimentalScoringId() -> int { return CurrentAIExperimentId(); }
|
||||
|
||||
auto ScoringExperimentForId(const int id) -> StandardScoringExperiment {
|
||||
StandardScoringExperiment experiment;
|
||||
|
||||
switch (id) {
|
||||
case 1: experiment.attackerUnitsScale = 1.05; break;
|
||||
case 2: experiment.attackerUnitsScale = 1.10; break;
|
||||
case 3: experiment.attackerUnitsScale = 0.95; break;
|
||||
case 4: experiment.attackerUnitsScale = 0.90; break;
|
||||
case 5: experiment.defenderUnitsScale = 0.95; break;
|
||||
case 6: experiment.defenderUnitsScale = 0.90; break;
|
||||
case 7: experiment.defenderUnitsScale = 1.05; break;
|
||||
case 8: experiment.defenderUnitsScale = 1.10; break;
|
||||
case 9: experiment.victoryConditionScale = 1.10; break;
|
||||
case 10: experiment.victoryConditionScale = 1.25; break;
|
||||
case 11: experiment.victoryConditionScale = 0.90; break;
|
||||
case 12: experiment.victoryConditionScale = 0.75; break;
|
||||
case 13: experiment.unitsBaseMultiplierScale = 0.90; break;
|
||||
case 14: experiment.unitsBaseMultiplierScale = 1.10; break;
|
||||
case 15: experiment.attackerRoundMultiplier = 0.90; break;
|
||||
case 16: experiment.attackerRoundMultiplier = 0.75; break;
|
||||
case 17: experiment.defenderRoundAdvantageScale = 0.50; break;
|
||||
case 18: experiment.defenderRoundAdvantageScale = 0.0; break;
|
||||
case 19:
|
||||
experiment.attackerUnitsScale = 1.10;
|
||||
experiment.victoryConditionScale = 1.10;
|
||||
break;
|
||||
case 20:
|
||||
experiment.defenderUnitsScale = 0.90;
|
||||
experiment.victoryConditionScale = 1.10;
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
|
||||
return experiment;
|
||||
}
|
||||
|
||||
auto AdjustDefenderUnitsForExperiment(
|
||||
double defenderUnitsValue,
|
||||
const int roundsRemaining,
|
||||
const int maxRounds,
|
||||
const StandardScoringExperiment &experiment) -> double {
|
||||
const double currentRound = static_cast<double>(maxRounds - roundsRemaining);
|
||||
const double standardAdvantage = 1.0 + currentRound / 31.0;
|
||||
if (standardAdvantage <= 0.0) { return defenderUnitsValue * experiment.defenderUnitsScale; }
|
||||
|
||||
const double unadjustedDefenderValue = defenderUnitsValue / standardAdvantage;
|
||||
const double adjustedAdvantage =
|
||||
1.0 + experiment.defenderRoundAdvantageScale * currentRound / 31.0;
|
||||
return unadjustedDefenderValue * adjustedAdvantage * experiment.defenderUnitsScale;
|
||||
}
|
||||
|
||||
std::atomic<int> AttackerScorePerformanceLogger::callCount{0};
|
||||
std::atomic<double> AttackerScorePerformanceLogger::intervalTime{0.0};
|
||||
std::atomic<double> AttackerScorePerformanceLogger::totalTime{0.0};
|
||||
@@ -160,27 +95,23 @@ public:
|
||||
StandardAIScoreCalculator(
|
||||
int maxRounds,
|
||||
ActionPoints braveWaterCost,
|
||||
ActionPoints archeryActionPointCost,
|
||||
int meteorRange,
|
||||
double meteorCastVigorCost,
|
||||
int minimumFleeOddsThreshold,
|
||||
int desperateFleeThreshold,
|
||||
std::vector<BattalionTypeSPtr> battalionTypes,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache,
|
||||
StandardScoringExperiment experiment = {})
|
||||
const ALCache &alCache)
|
||||
: AbstractAIScoreCalculator(
|
||||
maxRounds,
|
||||
braveWaterCost,
|
||||
archeryActionPointCost,
|
||||
meteorRange,
|
||||
meteorCastVigorCost,
|
||||
minimumFleeOddsThreshold,
|
||||
desperateFleeThreshold,
|
||||
std::move(battalionTypes),
|
||||
apdCache,
|
||||
alCache),
|
||||
experiment_(experiment) {}
|
||||
alCache) {}
|
||||
|
||||
[[nodiscard]] auto GuessedStateScore(
|
||||
bool isDefender,
|
||||
@@ -220,8 +151,6 @@ private:
|
||||
|
||||
[[nodiscard]] auto DefenderFleeStrategyScoreForState(const GameStateW &gameState) const
|
||||
-> ScoreValue override;
|
||||
|
||||
StandardScoringExperiment experiment_;
|
||||
};
|
||||
|
||||
// Implementation of StandardAIScoreCalculator methods
|
||||
@@ -230,7 +159,7 @@ auto StandardAIScoreCalculator::InterpretDefenderOutcome(GameOutcome outcome) co
|
||||
switch (outcome) {
|
||||
case GameOutcome::DEFENDER_VICTORY: return INT_MAX;
|
||||
case GameOutcome::ATTACKER_VICTORY: return INT_MIN;
|
||||
case GameOutcome::DRAW: return INT_MAX;
|
||||
case GameOutcome::DRAW: return 0;
|
||||
case GameOutcome::FLEE_OUTCOME: return 0;
|
||||
}
|
||||
throw ShardokInternalErrorException("Unknown GameOutcome");
|
||||
@@ -240,7 +169,7 @@ auto StandardAIScoreCalculator::InterpretAttackerOutcome(GameOutcome outcome) co
|
||||
switch (outcome) {
|
||||
case GameOutcome::ATTACKER_VICTORY: return INT_MAX;
|
||||
case GameOutcome::DEFENDER_VICTORY: return INT_MIN;
|
||||
case GameOutcome::DRAW: return INT_MIN;
|
||||
case GameOutcome::DRAW: return 0;
|
||||
case GameOutcome::FLEE_OUTCOME: return 0;
|
||||
}
|
||||
throw ShardokInternalErrorException("Unknown GameOutcome");
|
||||
@@ -312,20 +241,10 @@ auto StandardAIScoreCalculator::CombineAttackerScores(
|
||||
const UnitsScoreComponents &components,
|
||||
const double victoryConditionScore,
|
||||
const int roundsRemaining) const -> ScoreValue {
|
||||
const double attackerUnitsValue =
|
||||
components.attackerUnitsValue * experiment_.attackerUnitsScale;
|
||||
const double defenderUnitsValue = AdjustDefenderUnitsForExperiment(
|
||||
components.defenderUnitsValue,
|
||||
roundsRemaining,
|
||||
GetMaxRounds(),
|
||||
experiment_);
|
||||
const double unitsDifference = attackerUnitsValue - defenderUnitsValue;
|
||||
// Keep tactical unit value relevant late in the battle. The previous time decay could make
|
||||
// attackers undervalue decisive combat near the round limit.
|
||||
const double unitsMultiplier = experiment_.attackerRoundMultiplier;
|
||||
return UNITS_BASE_MULTIPLIER * experiment_.unitsBaseMultiplierScale * unitsMultiplier *
|
||||
unitsDifference +
|
||||
victoryConditionScore * experiment_.victoryConditionScale;
|
||||
const double unitsDifference = components.attackerUnitsValue - components.defenderUnitsValue;
|
||||
const double unitsMultiplier =
|
||||
static_cast<double>(roundsRemaining) / static_cast<double>(GetMaxRounds());
|
||||
return UNITS_BASE_MULTIPLIER * unitsMultiplier * unitsDifference + victoryConditionScore;
|
||||
}
|
||||
|
||||
auto StandardAIScoreCalculator::GuessedStateScore(
|
||||
@@ -342,11 +261,10 @@ auto StandardAIScoreCalculator::GuessedStateScore(
|
||||
}
|
||||
|
||||
// Factory function implementation
|
||||
auto MakeStandardAIScoreCalculatorWithExperiment(
|
||||
auto MakeStandardAIScoreCalculator(
|
||||
const SettingsGetter &settingsGetter,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache,
|
||||
const StandardScoringExperiment &experiment) -> std::unique_ptr<AIScoreCalculator> {
|
||||
const ALCache &alCache) -> std::unique_ptr<AIScoreCalculator> {
|
||||
// Extract all battalion types into a vector indexed by BattalionTypeId
|
||||
std::vector<BattalionTypeSPtr> battalionTypes(BattalionTypeId::BattalionTypeId_MAX + 1);
|
||||
for (int typeId = BattalionTypeId::BattalionTypeId_MIN;
|
||||
@@ -359,37 +277,13 @@ auto MakeStandardAIScoreCalculatorWithExperiment(
|
||||
return std::make_unique<StandardAIScoreCalculator>(
|
||||
settingsGetter.Backing().max_rounds(),
|
||||
settingsGetter.Backing().brave_water_action_point_cost(),
|
||||
settingsGetter.Backing().archery_action_point_cost(),
|
||||
settingsGetter.Backing().meteor_range(),
|
||||
settingsGetter.Backing().meteor_cast_vigor_cost(),
|
||||
settingsGetter.Backing().ai_minimum_flee_odds_threshold(),
|
||||
settingsGetter.Backing().ai_desperate_flee_threshold(),
|
||||
std::move(battalionTypes),
|
||||
apdCache,
|
||||
alCache,
|
||||
experiment);
|
||||
}
|
||||
|
||||
auto MakeStandardAIScoreCalculator(
|
||||
const SettingsGetter &settingsGetter,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache) -> std::unique_ptr<AIScoreCalculator> {
|
||||
return MakeStandardAIScoreCalculatorWithExperiment(
|
||||
settingsGetter,
|
||||
apdCache,
|
||||
alCache,
|
||||
StandardScoringExperiment{});
|
||||
}
|
||||
|
||||
auto MakeExperimentalAIScoreCalculator(
|
||||
const SettingsGetter &settingsGetter,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache) -> std::unique_ptr<AIScoreCalculator> {
|
||||
return MakeStandardAIScoreCalculatorWithExperiment(
|
||||
settingsGetter,
|
||||
apdCache,
|
||||
alCache,
|
||||
ScoringExperimentForId(ExperimentalScoringId()));
|
||||
alCache);
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
@@ -26,12 +26,6 @@ using ALCache = std::unique_ptr<AttackLocationsCache>;
|
||||
const APDCache& apdCache,
|
||||
const ALCache& alCache) -> std::unique_ptr<AIScoreCalculator>;
|
||||
|
||||
/// Factory function to create the environment-selected experimental scorer.
|
||||
[[nodiscard]] auto MakeExperimentalAIScoreCalculator(
|
||||
const SettingsGetter& settingsGetter,
|
||||
const APDCache& apdCache,
|
||||
const ALCache& alCache) -> std::unique_ptr<AIScoreCalculator>;
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_STANDARDAISCORECALCULATOR_HPP
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ constexpr double kMaxProximityBuf = 1.5;
|
||||
constexpr double kDistanceDebufRatio = 8.0;
|
||||
|
||||
/// Structure to hold separated attacker/defender unit scores
|
||||
/// Used by scorers that combine attacker and defender unit values separately.
|
||||
/// Used by NormalizedAIScoreCalculator to apply asymmetric normalization
|
||||
struct UnitsScoreComponents {
|
||||
double attackerUnitsValue;
|
||||
double defenderUnitsValue;
|
||||
|
||||
@@ -157,8 +157,7 @@ auto AbstractAIScoreCalculator::CalculateUnitsScoreComponents(
|
||||
locationsCausingDanger,
|
||||
notBravingApd,
|
||||
GetMeteorRange(),
|
||||
GetMeteorCastVigorCost(),
|
||||
GetArcheryActionPointCost());
|
||||
GetMeteorCastVigorCost());
|
||||
|
||||
attackerUnitsValue += distanceMultiplier * uv;
|
||||
}
|
||||
@@ -189,8 +188,7 @@ auto AbstractAIScoreCalculator::CalculateUnitsScoreComponents(
|
||||
locationsCausingDangerForAttacker,
|
||||
defenderNotBravingApd,
|
||||
GetMeteorRange(),
|
||||
GetMeteorCastVigorCost(),
|
||||
GetArcheryActionPointCost());
|
||||
GetMeteorCastVigorCost());
|
||||
|
||||
double distanceMultiplier = 1.0;
|
||||
|
||||
@@ -253,7 +251,7 @@ auto AbstractAIScoreCalculator::CalculateUnitsScoreComponents(
|
||||
braveWaterCost)
|
||||
: nullptr,
|
||||
cachedHexMap);
|
||||
if (thisDistance < closestDistanceToFriendly) {
|
||||
if (thisDistance < closestDistanceToEnemy) {
|
||||
closestDistanceToFriendly = thisDistance;
|
||||
}
|
||||
}
|
||||
@@ -508,7 +506,9 @@ auto AbstractAIScoreCalculator::AttackerScoreForState(
|
||||
const ScoreValue victoryConditionTotal =
|
||||
CalculateAttackerVictoryConditionScore(gameState, attackerStrategy, castleCoords);
|
||||
|
||||
// Combine scores using subclass-specific logic.
|
||||
// Combine scores using subclass-specific logic
|
||||
// Standard: uses difference and multiplier
|
||||
// Normalized: uses normalization formula
|
||||
return CombineAttackerScores(components, victoryConditionTotal, roundsRemaining);
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ protected:
|
||||
AbstractAIScoreCalculator(
|
||||
int maxRounds,
|
||||
ActionPoints braveWaterCost,
|
||||
ActionPoints archeryActionPointCost,
|
||||
int meteorRange,
|
||||
double meteorCastVigorCost,
|
||||
int minimumFleeOddsThreshold,
|
||||
@@ -54,7 +53,6 @@ protected:
|
||||
const ALCache &alCache)
|
||||
: maxRounds_(maxRounds),
|
||||
braveWaterCost_(braveWaterCost),
|
||||
archeryActionPointCost_(archeryActionPointCost),
|
||||
meteorRange_(meteorRange),
|
||||
meteorCastVigorCost_(meteorCastVigorCost),
|
||||
minimumFleeOddsThreshold_(minimumFleeOddsThreshold),
|
||||
@@ -72,9 +70,6 @@ protected:
|
||||
[[nodiscard]] auto GetApdCache() const -> const APDCache & { return apdCache_; }
|
||||
[[nodiscard]] auto GetAlCache() const -> const ALCache & { return alCache_; }
|
||||
[[nodiscard]] auto GetBraveWaterCost() const -> ActionPoints { return braveWaterCost_; }
|
||||
[[nodiscard]] auto GetArcheryActionPointCost() const -> ActionPoints {
|
||||
return archeryActionPointCost_;
|
||||
}
|
||||
[[nodiscard]] auto GetMaxRounds() const -> int { return maxRounds_; }
|
||||
[[nodiscard]] auto GetMeteorRange() const -> int { return meteorRange_; }
|
||||
[[nodiscard]] auto GetMeteorCastVigorCost() const -> double { return meteorCastVigorCost_; }
|
||||
@@ -151,8 +146,9 @@ protected:
|
||||
-> ScoreValue = 0;
|
||||
|
||||
// Pure virtual method for combining units score and victory condition score
|
||||
// This is where concrete scorers combine unit value and victory condition pressure.
|
||||
// Standard uses the raw unit-value difference; MCTS-optimized uses bounded scaling.
|
||||
// This is where Standard and Normalized diverge in their scoring approach
|
||||
// Standard: uses difference and applies multiplier
|
||||
// Normalized: uses normalization formula with separate attacker/defender values
|
||||
[[nodiscard]] virtual auto CombineAttackerScores(
|
||||
const UnitsScoreComponents &components,
|
||||
double victoryConditionScore,
|
||||
@@ -162,7 +158,6 @@ private:
|
||||
// Scalar settings extracted from SettingsGetter
|
||||
int maxRounds_;
|
||||
ActionPoints braveWaterCost_;
|
||||
ActionPoints archeryActionPointCost_;
|
||||
int meteorRange_;
|
||||
double meteorCastVigorCost_;
|
||||
int minimumFleeOddsThreshold_;
|
||||
|
||||
@@ -54,38 +54,30 @@ std::unique_ptr<BattleConfigProto> AiBattleConfigLoader::CreateDefaultPerfConfig
|
||||
// Basic setup matching Unity's "Perf" configuration
|
||||
config->set_map_name("Alah");
|
||||
config->set_month(month);
|
||||
config->set_max_rounds(31);
|
||||
config->set_random_seed(1);
|
||||
config->set_use_configured_placements(false);
|
||||
config->set_apply_forced_commands(true);
|
||||
config->set_max_rounds(40);
|
||||
config->set_random_seed(0); // Use system random
|
||||
|
||||
// Attacker configuration - 6 Longbowmen units with professions 1-6
|
||||
PlayerConfig* attacker = config->mutable_attacker();
|
||||
attacker->set_ai_algorithm(AIAlgorithmType::ITERATIVE_DEEPENING);
|
||||
attacker->set_scoring_calculator(
|
||||
net::eagle0::shardok::ai_battle_simulator::ScoringCalculatorType::STANDARD);
|
||||
|
||||
for (int profession = 1; profession <= 6; ++profession) {
|
||||
UnitConfig* unit = attacker->add_units();
|
||||
unit->set_profession(profession);
|
||||
unit->set_battalion_type_id(4); // Longbowmen (battalion type 4)
|
||||
unit->set_starting_position_index(0); // Attackers use position 0
|
||||
unit->set_can_flee(true);
|
||||
unit->set_battalion_type_id(4); // Longbowmen (battalion type 4)
|
||||
unit->set_starting_position_index(0); // Attackers use position 0
|
||||
unit->mutable_battalion()->set_size(1000); // Match client battalion size
|
||||
}
|
||||
|
||||
// Defender configuration - 6 Light Infantry units with NO_PROFESSION (profession 14)
|
||||
PlayerConfig* defender = config->mutable_defender();
|
||||
defender->set_ai_algorithm(AIAlgorithmType::ITERATIVE_DEEPENING);
|
||||
defender->set_scoring_calculator(
|
||||
net::eagle0::shardok::ai_battle_simulator::ScoringCalculatorType::STANDARD);
|
||||
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
UnitConfig* unit = defender->add_units();
|
||||
unit->set_profession(14); // NO_PROFESSION to match client battles
|
||||
unit->set_battalion_type_id(0); // Light Infantry (battalion type 0)
|
||||
unit->set_starting_position_index(-1); // Defenders use position -1
|
||||
unit->set_can_flee(true);
|
||||
unit->set_profession(14); // NO_PROFESSION to match client battles
|
||||
unit->set_battalion_type_id(0); // Light Infantry (battalion type 0)
|
||||
unit->set_starting_position_index(-1); // Defenders use position -1
|
||||
unit->mutable_battalion()->set_size(1000); // Match client battalion size
|
||||
}
|
||||
|
||||
|
||||
@@ -4,19 +4,14 @@
|
||||
|
||||
#include "AiBattleSimulator.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <iostream>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.hpp"
|
||||
#include "src/main/cpp/net/eagle0/common/RandomGenerator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/AIClientFactory.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/GamePhaseRunner.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/GameSettingsFactory.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/UnitPlacementInfo.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/fb_helpers/GameStateHelpers.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/util/MapLoader.hpp"
|
||||
@@ -50,67 +45,6 @@ constexpr int DEFAULT_HERO_INTEGRITY = 0;
|
||||
constexpr int DEFAULT_HERO_AMBITION = 0;
|
||||
constexpr int DEFAULT_HERO_VIGOR = 102;
|
||||
|
||||
constexpr int PROFESSION_MAGE = 1;
|
||||
constexpr double MINIMUM_AGILITY_FOR_ARCHERY = 75.0;
|
||||
constexpr double MINIMUM_AGILITY_FOR_START_FIRE = 70.0;
|
||||
constexpr double MINIMUM_WISDOM_FOR_START_FIRE = 70.0;
|
||||
|
||||
struct SideTotals {
|
||||
int attackerUnits = 0;
|
||||
int defenderUnits = 0;
|
||||
double attackerTroops = 0.0;
|
||||
double defenderTroops = 0.0;
|
||||
};
|
||||
|
||||
std::string JsonEscape(const std::string& value) {
|
||||
std::ostringstream out;
|
||||
for (const char c : value) {
|
||||
switch (c) {
|
||||
case '"': out << "\\\""; break;
|
||||
case '\\': out << "\\\\"; break;
|
||||
case '\n': out << "\\n"; break;
|
||||
case '\r': out << "\\r"; break;
|
||||
case '\t': out << "\\t"; break;
|
||||
default: out << c; break;
|
||||
}
|
||||
}
|
||||
return out.str();
|
||||
}
|
||||
|
||||
std::string CompletionReasonLabel(EvaluationCompletionReason reason) {
|
||||
switch (reason) {
|
||||
case EvaluationCompletionReason::RAN_OUT_OF_COMMANDS: return "ran_out_of_commands";
|
||||
case EvaluationCompletionReason::RAN_OUT_OF_TIME: return "ran_out_of_time";
|
||||
case EvaluationCompletionReason::NOT_ENOUGH_TIME_TO_CONTINUE:
|
||||
return "not_enough_time_to_continue";
|
||||
}
|
||||
}
|
||||
|
||||
SideTotals CalculateSideTotals(const GameStateW& state) {
|
||||
SideTotals totals;
|
||||
const auto* units = state->units();
|
||||
if (units == nullptr) { return totals; }
|
||||
|
||||
for (const auto* unit : *units) {
|
||||
if (unit == nullptr) { continue; }
|
||||
const bool isAlive =
|
||||
unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT ||
|
||||
unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT ||
|
||||
unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_NEVER_ENTERED_UNIT;
|
||||
if (!isAlive) { continue; }
|
||||
|
||||
if (unit->player_id() == ATTACKER_ID) {
|
||||
totals.attackerUnits++;
|
||||
totals.attackerTroops += unit->battalion().size();
|
||||
} else if (unit->player_id() == DEFENDER_ID) {
|
||||
totals.defenderUnits++;
|
||||
totals.defenderTroops += unit->battalion().size();
|
||||
}
|
||||
}
|
||||
|
||||
return totals;
|
||||
}
|
||||
|
||||
// Convert proto AI algorithm type to ShardokAI enum
|
||||
AIAlgorithmType ConvertAIAlgorithmType(
|
||||
net::eagle0::shardok::ai_battle_simulator::AIAlgorithmType protoType) {
|
||||
@@ -121,160 +55,21 @@ AIAlgorithmType ConvertAIAlgorithmType(
|
||||
}
|
||||
}
|
||||
|
||||
ScoringCalculatorType ConvertScoringCalculatorType(
|
||||
net::eagle0::shardok::ai_battle_simulator::ScoringCalculatorType protoType) {
|
||||
switch (protoType) {
|
||||
case net::eagle0::shardok::ai_battle_simulator::EXPERIMENTAL:
|
||||
return ScoringCalculatorType::EXPERIMENTAL;
|
||||
case net::eagle0::shardok::ai_battle_simulator::MCTS_OPTIMIZED:
|
||||
return ScoringCalculatorType::MCTS_OPTIMIZED;
|
||||
case net::eagle0::shardok::ai_battle_simulator::STANDARD:
|
||||
case net::eagle0::shardok::ai_battle_simulator::SCORING_CALCULATOR_TYPE_UNSPECIFIED:
|
||||
default: return ScoringCalculatorType::STANDARD;
|
||||
}
|
||||
}
|
||||
|
||||
mcts::MCTSSimulationPolicy ConvertSimulationPolicy(
|
||||
net::eagle0::shardok::ai_battle_simulator::MctsSimulationPolicy protoType) {
|
||||
switch (protoType) {
|
||||
case net::eagle0::shardok::ai_battle_simulator::RANDOM:
|
||||
return mcts::MCTSSimulationPolicy::RANDOM;
|
||||
case net::eagle0::shardok::ai_battle_simulator::FILTERED_RANDOM:
|
||||
return mcts::MCTSSimulationPolicy::FILTERED_RANDOM;
|
||||
case net::eagle0::shardok::ai_battle_simulator::BEST_IMMEDIATE:
|
||||
return mcts::MCTSSimulationPolicy::BEST_IMMEDIATE;
|
||||
case net::eagle0::shardok::ai_battle_simulator::WEIGHTED_BEST_IMMEDIATE:
|
||||
return mcts::MCTSSimulationPolicy::WEIGHTED_BEST_IMMEDIATE;
|
||||
case net::eagle0::shardok::ai_battle_simulator::WEIGHTED_HEURISTIC:
|
||||
case net::eagle0::shardok::ai_battle_simulator::MCTS_SIMULATION_POLICY_UNSPECIFIED:
|
||||
default: return mcts::MCTSSimulationPolicy::WEIGHTED_HEURISTIC;
|
||||
}
|
||||
}
|
||||
|
||||
mcts::MCTSBackpropagationPolicy ConvertBackpropagationPolicy(
|
||||
net::eagle0::shardok::ai_battle_simulator::MctsBackpropagationPolicy protoType) {
|
||||
switch (protoType) {
|
||||
case net::eagle0::shardok::ai_battle_simulator::AVERAGING:
|
||||
return mcts::MCTSBackpropagationPolicy::AVERAGING;
|
||||
case net::eagle0::shardok::ai_battle_simulator::MINIMAX:
|
||||
case net::eagle0::shardok::ai_battle_simulator::MCTS_BACKPROPAGATION_POLICY_UNSPECIFIED:
|
||||
default: return mcts::MCTSBackpropagationPolicy::MINIMAX;
|
||||
}
|
||||
}
|
||||
|
||||
mcts::MCTSConfig BuildMCTSConfig(
|
||||
const net::eagle0::shardok::ai_battle_simulator::PlayerConfig& playerConfig,
|
||||
int battleSeed,
|
||||
PlayerId playerId) {
|
||||
mcts::MCTSConfig config{};
|
||||
config.backpropagationPolicy = mcts::MCTSBackpropagationPolicy::MINIMAX;
|
||||
config.maxPlayerFlips = 1;
|
||||
config.maxSimulationFlips = 1;
|
||||
config.simulationPolicy = mcts::MCTSSimulationPolicy::WEIGHTED_HEURISTIC;
|
||||
if (battleSeed != 0) { config.useMultithreading = false; }
|
||||
if (battleSeed != 0) {
|
||||
config.randomSeed = static_cast<uint32_t>(battleSeed + (1009 * playerId));
|
||||
}
|
||||
|
||||
if (!playerConfig.has_mcts_config()) { return config; }
|
||||
|
||||
const auto& protoConfig = playerConfig.mcts_config();
|
||||
if (protoConfig.exploration_constant() > 0.0) {
|
||||
config.explorationConstant = protoConfig.exploration_constant();
|
||||
}
|
||||
if (protoConfig.max_simulation_depth() > 0) {
|
||||
config.maxSimulationDepth = protoConfig.max_simulation_depth();
|
||||
}
|
||||
if (protoConfig.max_tree_depth() > 0) { config.maxTreeDepth = protoConfig.max_tree_depth(); }
|
||||
config.useMultithreading = protoConfig.use_multithreading();
|
||||
if (protoConfig.num_threads() > 0) { config.numThreads = protoConfig.num_threads(); }
|
||||
config.simulationPolicy = ConvertSimulationPolicy(protoConfig.simulation_policy());
|
||||
config.backpropagationPolicy =
|
||||
ConvertBackpropagationPolicy(protoConfig.backpropagation_policy());
|
||||
if (protoConfig.max_player_flips() >= 0) {
|
||||
config.maxPlayerFlips = protoConfig.max_player_flips();
|
||||
}
|
||||
if (protoConfig.max_simulation_flips() >= 0) {
|
||||
config.maxSimulationFlips = protoConfig.max_simulation_flips();
|
||||
}
|
||||
if (protoConfig.random_seed() != 0) {
|
||||
config.randomSeed = static_cast<uint32_t>(protoConfig.random_seed());
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
bool HasAttachedHero(int profession) { return profession > 0; }
|
||||
|
||||
// Get value with default if not set (proto3 uses 0 as default, we check for that)
|
||||
template<typename T>
|
||||
T GetOrDefault(T value, T defaultValue) {
|
||||
return (value == 0) ? defaultValue : value;
|
||||
}
|
||||
|
||||
bool HeroCanArchery(const net::eagle0::shardok::ai_battle_simulator::UnitConfig& unitConfig) {
|
||||
if (!HasAttachedHero(unitConfig.profession())) { return false; }
|
||||
return GetOrDefault(unitConfig.hero().agility(), DEFAULT_HERO_AGILITY) >=
|
||||
MINIMUM_AGILITY_FOR_ARCHERY;
|
||||
}
|
||||
|
||||
bool HeroCanStartFire(const net::eagle0::shardok::ai_battle_simulator::UnitConfig& unitConfig) {
|
||||
if (!HasAttachedHero(unitConfig.profession())) { return false; }
|
||||
return unitConfig.profession() == PROFESSION_MAGE ||
|
||||
(GetOrDefault(unitConfig.hero().agility(), DEFAULT_HERO_AGILITY) >=
|
||||
MINIMUM_AGILITY_FOR_START_FIRE &&
|
||||
GetOrDefault(unitConfig.hero().wisdom(), DEFAULT_HERO_WISDOM) >=
|
||||
MINIMUM_WISDOM_FOR_START_FIRE);
|
||||
}
|
||||
|
||||
double GetBattalionSizeOrDefault(
|
||||
const net::eagle0::shardok::ai_battle_simulator::BattalionConfig& battalion,
|
||||
double defaultValue) {
|
||||
return battalion.has_size() ? battalion.size() : defaultValue;
|
||||
}
|
||||
|
||||
bool GetCanFleeOrDefault(const net::eagle0::shardok::ai_battle_simulator::UnitConfig& unitConfig) {
|
||||
return unitConfig.has_can_flee() ? unitConfig.can_flee() : true;
|
||||
}
|
||||
|
||||
double GetBattalionArmamentOrDefault(
|
||||
const net::eagle0::shardok::ai_battle_simulator::BattalionConfig& battalion) {
|
||||
return battalion.has_armament() ? battalion.armament() : DEFAULT_BATTALION_ARMAMENT;
|
||||
}
|
||||
|
||||
double GetBattalionTrainingOrDefault(
|
||||
const net::eagle0::shardok::ai_battle_simulator::BattalionConfig& battalion) {
|
||||
return battalion.has_training() ? battalion.training() : DEFAULT_BATTALION_TRAINING;
|
||||
}
|
||||
|
||||
double GetBattalionMoraleOrDefault(
|
||||
const net::eagle0::shardok::ai_battle_simulator::BattalionConfig& battalion) {
|
||||
return battalion.has_morale() ? battalion.morale() : DEFAULT_BATTALION_MORALE;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
AiBattleSimulator::AiBattleSimulator(
|
||||
const BattleConfigProto& config,
|
||||
GameSettingsSPtr gameSettings,
|
||||
std::optional<GameStateW> initialGameState,
|
||||
BattleTraceOptions traceOptions)
|
||||
AiBattleSimulator::AiBattleSimulator(const BattleConfigProto& config, GameSettingsSPtr gameSettings)
|
||||
: config_(config),
|
||||
gameSettings_(std::move(gameSettings)),
|
||||
initialGameState_(std::move(initialGameState)),
|
||||
traceOptions_(std::move(traceOptions)) {
|
||||
gameSettings_(std::move(gameSettings)) {
|
||||
if (!gameSettings_) {
|
||||
// Initialize default game settings
|
||||
gameSettings_ = ai_testing_common::GameSettingsFactory::CreateDefault();
|
||||
}
|
||||
if (config_.random_seed() != 0) {
|
||||
auto setter = gameSettings_->GetSetter();
|
||||
setter.SetRandomGenerator(std::make_shared<StdLibraryGenerator>(
|
||||
static_cast<std::mt19937_64::result_type>(config_.random_seed())));
|
||||
}
|
||||
if (config_.max_rounds() > 0) {
|
||||
auto setter = gameSettings_->GetSetter();
|
||||
setter.SetInt("maxRounds", config_.max_rounds());
|
||||
}
|
||||
}
|
||||
|
||||
BattleResult AiBattleSimulator::RunBattle() {
|
||||
@@ -282,7 +77,6 @@ BattleResult AiBattleSimulator::RunBattle() {
|
||||
std::cout << " Map: " << config_.map_name() << "\n";
|
||||
std::cout << " Month: " << config_.month() << "\n";
|
||||
std::cout << " Max rounds: " << config_.max_rounds() << "\n";
|
||||
std::cout << " Random seed: " << config_.random_seed() << "\n";
|
||||
std::cout << " Attacker AI: "
|
||||
<< net::eagle0::shardok::ai_battle_simulator::AIAlgorithmType_Name(
|
||||
config_.attacker().ai_algorithm())
|
||||
@@ -293,7 +87,7 @@ BattleResult AiBattleSimulator::RunBattle() {
|
||||
<< "\n";
|
||||
|
||||
// Create initial game state
|
||||
auto gameState = initialGameState_.has_value() ? *initialGameState_ : CreateInitialGameState();
|
||||
auto gameState = CreateInitialGameState();
|
||||
|
||||
// Get hex map from game state
|
||||
const auto* hexMap = gameState->hex_map();
|
||||
@@ -305,25 +99,17 @@ BattleResult AiBattleSimulator::RunBattle() {
|
||||
// Create the engine once for the entire simulation
|
||||
ShardokEngine engine(gameSettings_, gameState);
|
||||
|
||||
int setupCommands = 0;
|
||||
if (!initialGameState_.has_value()) {
|
||||
// Run setup phase
|
||||
std::cout << "\nStarting setup phase...\n";
|
||||
auto setupResult = config_.use_configured_placements()
|
||||
? RunConfiguredSetupPhase(engine)
|
||||
: RunSetupPhase(engine, *attackerAI, *defenderAI);
|
||||
if (setupResult.endReason != BattleResult::EndReason::DRAW) {
|
||||
// Game ended during setup (shouldn't happen normally)
|
||||
return setupResult;
|
||||
}
|
||||
setupCommands = setupResult.totalCommands;
|
||||
} else {
|
||||
std::cout << "\nStarting from saved Shardok state; skipping setup phase.\n";
|
||||
// Run setup phase
|
||||
std::cout << "\nStarting setup phase...\n";
|
||||
auto setupResult = RunSetupPhase(engine, *attackerAI, *defenderAI);
|
||||
if (setupResult.endReason != BattleResult::EndReason::DRAW) {
|
||||
// Game ended during setup (shouldn't happen normally)
|
||||
return setupResult;
|
||||
}
|
||||
|
||||
// Run battle phase
|
||||
std::cout << "\nStarting battle phase...\n";
|
||||
return RunBattlePhase(engine, *attackerAI, *defenderAI, setupCommands);
|
||||
return RunBattlePhase(engine, *attackerAI, *defenderAI);
|
||||
}
|
||||
|
||||
GameStateW AiBattleSimulator::CreateInitialGameState() const {
|
||||
@@ -371,7 +157,7 @@ GameStateW AiBattleSimulator::CreateInitialGameState() const {
|
||||
unit.mutate_remaining_action_points(12);
|
||||
unit.mutate_hidden(false);
|
||||
unit.mutate_fortified(false);
|
||||
unit.mutate_can_flee(GetCanFleeOrDefault(unitConfig));
|
||||
unit.mutate_can_flee(true);
|
||||
unit.mutate_can_start_fire(false);
|
||||
unit.mutate_can_archery(false);
|
||||
unit.mutate_stun_rounds_remaining(0);
|
||||
@@ -392,17 +178,18 @@ GameStateW AiBattleSimulator::CreateInitialGameState() const {
|
||||
// Get battalion type to determine default capacity
|
||||
const auto& battalionType = gameSettings_->GetGetter().GetBattalionType(battalionTypeId);
|
||||
const double defaultSize = battalionType->capacity;
|
||||
unit.mutate_can_archery(battalionType->alwaysArcheryCapable || HeroCanArchery(unitConfig));
|
||||
unit.mutate_can_start_fire(HeroCanStartFire(unitConfig));
|
||||
|
||||
battalion.mutate_size(GetBattalionSizeOrDefault(unitConfig.battalion(), defaultSize));
|
||||
battalion.mutate_armament(GetBattalionArmamentOrDefault(unitConfig.battalion()));
|
||||
battalion.mutate_training(GetBattalionTrainingOrDefault(unitConfig.battalion()));
|
||||
battalion.mutate_morale(GetBattalionMoraleOrDefault(unitConfig.battalion()));
|
||||
battalion.mutate_size(GetOrDefault(unitConfig.battalion().size(), defaultSize));
|
||||
battalion.mutate_armament(
|
||||
GetOrDefault(unitConfig.battalion().armament(), DEFAULT_BATTALION_ARMAMENT));
|
||||
battalion.mutate_training(
|
||||
GetOrDefault(unitConfig.battalion().training(), DEFAULT_BATTALION_TRAINING));
|
||||
battalion.mutate_morale(
|
||||
GetOrDefault(unitConfig.battalion().morale(), DEFAULT_BATTALION_MORALE));
|
||||
unit.mutable_battalion() = battalion;
|
||||
|
||||
// Hero
|
||||
if (HasAttachedHero(unitConfig.profession())) {
|
||||
if (unitConfig.profession() > 0) {
|
||||
unit.mutate_has_attached_hero(true);
|
||||
|
||||
net::eagle0::shardok::storage::fb::Hero hero;
|
||||
@@ -441,9 +228,6 @@ GameStateW AiBattleSimulator::CreateInitialGameState() const {
|
||||
} else {
|
||||
unit.mutate_has_attached_hero(false);
|
||||
}
|
||||
if (battalion.size() <= 0 && !unit.has_attached_hero()) {
|
||||
unit.mutate_status(net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT);
|
||||
}
|
||||
|
||||
// Initialize opponent knowledge
|
||||
unit.mutable_opponent_knowledge()->Mutate(0, 0);
|
||||
@@ -466,7 +250,7 @@ GameStateW AiBattleSimulator::CreateInitialGameState() const {
|
||||
unit.mutate_remaining_action_points(0); // Player 1 starts with 0 AP (not their turn yet)
|
||||
unit.mutate_hidden(false);
|
||||
unit.mutate_fortified(false);
|
||||
unit.mutate_can_flee(GetCanFleeOrDefault(unitConfig));
|
||||
unit.mutate_can_flee(true);
|
||||
unit.mutate_can_start_fire(false);
|
||||
unit.mutate_can_archery(false);
|
||||
unit.mutate_stun_rounds_remaining(0);
|
||||
@@ -487,17 +271,18 @@ GameStateW AiBattleSimulator::CreateInitialGameState() const {
|
||||
// Get battalion type to determine default capacity
|
||||
const auto& battalionType = gameSettings_->GetGetter().GetBattalionType(battalionTypeId);
|
||||
const double defaultSize = battalionType->capacity;
|
||||
unit.mutate_can_archery(battalionType->alwaysArcheryCapable || HeroCanArchery(unitConfig));
|
||||
unit.mutate_can_start_fire(HeroCanStartFire(unitConfig));
|
||||
|
||||
battalion.mutate_size(GetBattalionSizeOrDefault(unitConfig.battalion(), defaultSize));
|
||||
battalion.mutate_armament(GetBattalionArmamentOrDefault(unitConfig.battalion()));
|
||||
battalion.mutate_training(GetBattalionTrainingOrDefault(unitConfig.battalion()));
|
||||
battalion.mutate_morale(GetBattalionMoraleOrDefault(unitConfig.battalion()));
|
||||
battalion.mutate_size(GetOrDefault(unitConfig.battalion().size(), defaultSize));
|
||||
battalion.mutate_armament(
|
||||
GetOrDefault(unitConfig.battalion().armament(), DEFAULT_BATTALION_ARMAMENT));
|
||||
battalion.mutate_training(
|
||||
GetOrDefault(unitConfig.battalion().training(), DEFAULT_BATTALION_TRAINING));
|
||||
battalion.mutate_morale(
|
||||
GetOrDefault(unitConfig.battalion().morale(), DEFAULT_BATTALION_MORALE));
|
||||
unit.mutable_battalion() = battalion;
|
||||
|
||||
// Hero
|
||||
if (HasAttachedHero(unitConfig.profession())) {
|
||||
if (unitConfig.profession() > 0) {
|
||||
unit.mutate_has_attached_hero(true);
|
||||
|
||||
net::eagle0::shardok::storage::fb::Hero hero;
|
||||
@@ -536,9 +321,6 @@ GameStateW AiBattleSimulator::CreateInitialGameState() const {
|
||||
} else {
|
||||
unit.mutate_has_attached_hero(false);
|
||||
}
|
||||
if (battalion.size() <= 0 && !unit.has_attached_hero()) {
|
||||
unit.mutate_status(net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT);
|
||||
}
|
||||
|
||||
// Initialize opponent knowledge
|
||||
unit.mutable_opponent_knowledge()->Mutate(0, 0);
|
||||
@@ -566,19 +348,13 @@ std::unique_ptr<ShardokAIClient> AiBattleSimulator::CreateAIClient(
|
||||
const bool isDefender = (playerId == DEFENDER_ID);
|
||||
|
||||
AIAlgorithmType algorithmType = ConvertAIAlgorithmType(playerConfig.ai_algorithm());
|
||||
const ScoringCalculatorType scoringType =
|
||||
ConvertScoringCalculatorType(playerConfig.scoring_calculator());
|
||||
const auto mctsConfig = BuildMCTSConfig(playerConfig, config_.random_seed(), playerId);
|
||||
|
||||
return ai_testing_common::AIClientFactory::Create(
|
||||
playerId,
|
||||
isDefender,
|
||||
hexMap,
|
||||
gameSettings_->GetGetter(),
|
||||
algorithmType,
|
||||
scoringType,
|
||||
true,
|
||||
mctsConfig);
|
||||
algorithmType);
|
||||
}
|
||||
|
||||
BattleResult AiBattleSimulator::RunSetupPhase(
|
||||
@@ -589,67 +365,7 @@ BattleResult AiBattleSimulator::RunSetupPhase(
|
||||
return (playerId == ATTACKER_ID) ? attackerAI : defenderAI;
|
||||
};
|
||||
|
||||
ai_testing_common::PhaseResult phaseResult;
|
||||
|
||||
while (engine.GetCurrentGameState()->status()->state() ==
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP) {
|
||||
auto currentState = engine.GetCurrentGameState();
|
||||
PlayerId currentPlayer = currentState->current_player();
|
||||
|
||||
auto availableCommands = engine.GetAvailableCommandProtos(currentPlayer, false);
|
||||
if (availableCommands.empty()) { break; }
|
||||
|
||||
ShardokAIClient& activeAI = getAI(currentPlayer);
|
||||
const SideTotals preTotals = CalculateSideTotals(currentState);
|
||||
auto choiceResults = activeAI.ChooseCommandIndex(engine);
|
||||
const auto command = availableCommands.at(choiceResults.chosenIndex);
|
||||
|
||||
const auto randomGenerator = gameSettings_->GetGetter().GetRandomGenerator();
|
||||
engine.PostCommand(currentPlayer, choiceResults.chosenIndex, randomGenerator);
|
||||
phaseResult.commandsExecuted++;
|
||||
const auto postState = engine.GetCurrentGameState();
|
||||
const SideTotals postTotals = CalculateSideTotals(postState);
|
||||
|
||||
if (traceOptions_.output != nullptr) {
|
||||
*traceOptions_.output
|
||||
<< "{"
|
||||
<< "\"run_label\":\"" << JsonEscape(traceOptions_.runLabel) << "\","
|
||||
<< "\"config\":\"" << JsonEscape(traceOptions_.configPath) << "\","
|
||||
<< "\"state_file\":\"" << JsonEscape(traceOptions_.statePath) << "\","
|
||||
<< "\"phase\":\"setup\","
|
||||
<< "\"sequence\":" << phaseResult.commandsExecuted << ","
|
||||
<< "\"round\":" << static_cast<int>(currentState->current_round()) << ","
|
||||
<< "\"player\":" << static_cast<int>(currentPlayer) << ","
|
||||
<< "\"side\":\"" << (currentPlayer == ATTACKER_ID ? "attacker" : "defender")
|
||||
<< "\","
|
||||
<< "\"command_type\":\""
|
||||
<< net::eagle0::shardok::common::CommandType_Name(command.type()) << "\","
|
||||
<< "\"actor_unit\":" << (command.has_actor() ? command.actor().value() : -1)
|
||||
<< ","
|
||||
<< "\"target_row\":" << command.target().row() << ","
|
||||
<< "\"target_column\":" << command.target().column() << ","
|
||||
<< "\"chosen_index\":" << choiceResults.chosenIndex << ","
|
||||
<< "\"available_command_count\":" << choiceResults.availableCommandCount << ","
|
||||
<< "\"depth_achieved\":" << choiceResults.depthAchieved << ","
|
||||
<< "\"commands_evaluated\":" << choiceResults.commandCountEvaluated << ","
|
||||
<< "\"completion_reason\":\""
|
||||
<< CompletionReasonLabel(choiceResults.completionReason) << "\","
|
||||
<< "\"forced_commands\":0,"
|
||||
<< "\"pre_attacker_units\":" << preTotals.attackerUnits << ","
|
||||
<< "\"pre_defender_units\":" << preTotals.defenderUnits << ","
|
||||
<< "\"pre_attacker_troops\":" << preTotals.attackerTroops << ","
|
||||
<< "\"pre_defender_troops\":" << preTotals.defenderTroops << ","
|
||||
<< "\"post_attacker_units\":" << postTotals.attackerUnits << ","
|
||||
<< "\"post_defender_units\":" << postTotals.defenderUnits << ","
|
||||
<< "\"post_attacker_troops\":" << postTotals.attackerTroops << ","
|
||||
<< "\"post_defender_troops\":" << postTotals.defenderTroops << "}\n";
|
||||
}
|
||||
|
||||
if (engine.GameIsOver()) {
|
||||
phaseResult.gameEnded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
auto phaseResult = ai_testing_common::GamePhaseRunner::RunSetupPhase(engine, getAI);
|
||||
|
||||
std::cout << "Setup phase complete. Commands executed: " << phaseResult.commandsExecuted
|
||||
<< "\n";
|
||||
@@ -672,72 +388,14 @@ BattleResult AiBattleSimulator::RunSetupPhase(
|
||||
return result;
|
||||
}
|
||||
|
||||
BattleResult AiBattleSimulator::RunConfiguredSetupPhase(ShardokEngine& engine) {
|
||||
auto placePlayer = [&](PlayerId playerId,
|
||||
const net::eagle0::shardok::ai_battle_simulator::PlayerConfig&
|
||||
playerConfig,
|
||||
int unitIdOffset) {
|
||||
std::vector<UnitPlacementInfo> placements;
|
||||
placements.reserve(playerConfig.units_size());
|
||||
for (int i = 0; i < playerConfig.units_size(); ++i) {
|
||||
const auto& unitConfig = playerConfig.units(i);
|
||||
if (!unitConfig.has_fixed_position()) {
|
||||
throw std::invalid_argument(
|
||||
"use_configured_placements requires has_fixed_position for every unit");
|
||||
}
|
||||
placements.emplace_back(
|
||||
static_cast<UnitId>(unitIdOffset + i),
|
||||
Coords(unitConfig.row(), unitConfig.column()));
|
||||
}
|
||||
|
||||
const auto randomGenerator = gameSettings_->GetGetter().GetRandomGenerator();
|
||||
engine.PostPlacementCommands(playerId, placements, randomGenerator);
|
||||
engine.PostFinishedPlacementCommand(playerId, randomGenerator);
|
||||
if (config_.apply_forced_commands()) {
|
||||
engine.PostWhileCurrentPlayerHasOnlyOneOption(randomGenerator);
|
||||
}
|
||||
};
|
||||
|
||||
placePlayer(ATTACKER_ID, config_.attacker(), 0);
|
||||
if (!engine.GameIsOver()) {
|
||||
placePlayer(DEFENDER_ID, config_.defender(), config_.attacker().units_size());
|
||||
}
|
||||
|
||||
std::cout << "Configured setup complete.\n";
|
||||
|
||||
if (engine.GameIsOver()) {
|
||||
return CreateResultFromGameState(
|
||||
engine.GetCurrentGameState(),
|
||||
0,
|
||||
static_cast<int>(engine.GetUnfilteredHistoryCount()));
|
||||
}
|
||||
|
||||
BattleResult result;
|
||||
result.winner = -1;
|
||||
result.totalRounds = 0;
|
||||
result.totalCommands = static_cast<int>(engine.GetUnfilteredHistoryCount());
|
||||
result.endReason = BattleResult::EndReason::DRAW;
|
||||
result.description = "Configured setup completed";
|
||||
return result;
|
||||
}
|
||||
|
||||
BattleResult AiBattleSimulator::RunBattlePhase(
|
||||
ShardokEngine& engine,
|
||||
ShardokAIClient& attackerAI,
|
||||
ShardokAIClient& defenderAI,
|
||||
int setupCommands) {
|
||||
int totalCommands = setupCommands;
|
||||
int attackerCommands = 0;
|
||||
int defenderCommands = 0;
|
||||
int attackerDepthTotal = 0;
|
||||
int defenderDepthTotal = 0;
|
||||
int currentRound = std::max<int>(1, engine.GetCurrentGameState()->current_round());
|
||||
ShardokAIClient& defenderAI) {
|
||||
int totalCommands = 0;
|
||||
int currentRound = 1;
|
||||
|
||||
const int harnessMaxRounds = config_.max_rounds() > 0
|
||||
? config_.max_rounds()
|
||||
: gameSettings_->GetGetter().Backing().max_rounds();
|
||||
|
||||
while (!engine.GameIsOver() && currentRound <= harnessMaxRounds) {
|
||||
while (!engine.GameIsOver() && currentRound <= config_.max_rounds()) {
|
||||
auto currentState = engine.GetCurrentGameState();
|
||||
PlayerId currentPlayer = currentState->current_player();
|
||||
|
||||
@@ -752,66 +410,14 @@ BattleResult AiBattleSimulator::RunBattlePhase(
|
||||
ShardokAIClient& activeAI = (currentPlayer == ATTACKER_ID) ? attackerAI : defenderAI;
|
||||
|
||||
// Get AI decision
|
||||
const SideTotals preTotals = CalculateSideTotals(currentState);
|
||||
auto choiceResults = activeAI.ChooseCommandIndex(engine);
|
||||
const auto command = availableCommands.at(choiceResults.chosenIndex);
|
||||
|
||||
// Apply command
|
||||
const auto randomGenerator = gameSettings_->GetGetter().GetRandomGenerator();
|
||||
engine.PostCommand(currentPlayer, choiceResults.chosenIndex, randomGenerator);
|
||||
engine.PostCommand(currentPlayer, choiceResults.chosenIndex);
|
||||
totalCommands++;
|
||||
int forcedCommands = 0;
|
||||
if (currentPlayer == ATTACKER_ID) {
|
||||
attackerCommands++;
|
||||
attackerDepthTotal += choiceResults.depthAchieved;
|
||||
} else if (currentPlayer == DEFENDER_ID) {
|
||||
defenderCommands++;
|
||||
defenderDepthTotal += choiceResults.depthAchieved;
|
||||
}
|
||||
|
||||
if (config_.apply_forced_commands()) {
|
||||
forcedCommands = engine.PostWhileCurrentPlayerHasOnlyOneOption(randomGenerator);
|
||||
totalCommands += forcedCommands;
|
||||
}
|
||||
|
||||
// Check round progression
|
||||
auto newState = engine.GetCurrentGameState();
|
||||
const SideTotals postTotals = CalculateSideTotals(newState);
|
||||
if (traceOptions_.output != nullptr) {
|
||||
const int sequence = totalCommands - forcedCommands;
|
||||
*traceOptions_.output
|
||||
<< "{"
|
||||
<< "\"run_label\":\"" << JsonEscape(traceOptions_.runLabel) << "\","
|
||||
<< "\"config\":\"" << JsonEscape(traceOptions_.configPath) << "\","
|
||||
<< "\"state_file\":\"" << JsonEscape(traceOptions_.statePath) << "\","
|
||||
<< "\"phase\":\"battle\","
|
||||
<< "\"sequence\":" << sequence << ","
|
||||
<< "\"round\":" << static_cast<int>(currentState->current_round()) << ","
|
||||
<< "\"player\":" << static_cast<int>(currentPlayer) << ","
|
||||
<< "\"side\":\"" << (currentPlayer == ATTACKER_ID ? "attacker" : "defender")
|
||||
<< "\","
|
||||
<< "\"command_type\":\""
|
||||
<< net::eagle0::shardok::common::CommandType_Name(command.type()) << "\","
|
||||
<< "\"actor_unit\":" << (command.has_actor() ? command.actor().value() : -1)
|
||||
<< ","
|
||||
<< "\"target_row\":" << command.target().row() << ","
|
||||
<< "\"target_column\":" << command.target().column() << ","
|
||||
<< "\"chosen_index\":" << choiceResults.chosenIndex << ","
|
||||
<< "\"available_command_count\":" << choiceResults.availableCommandCount << ","
|
||||
<< "\"depth_achieved\":" << choiceResults.depthAchieved << ","
|
||||
<< "\"commands_evaluated\":" << choiceResults.commandCountEvaluated << ","
|
||||
<< "\"completion_reason\":\""
|
||||
<< CompletionReasonLabel(choiceResults.completionReason) << "\","
|
||||
<< "\"forced_commands\":" << forcedCommands << ","
|
||||
<< "\"pre_attacker_units\":" << preTotals.attackerUnits << ","
|
||||
<< "\"pre_defender_units\":" << preTotals.defenderUnits << ","
|
||||
<< "\"pre_attacker_troops\":" << preTotals.attackerTroops << ","
|
||||
<< "\"pre_defender_troops\":" << preTotals.defenderTroops << ","
|
||||
<< "\"post_attacker_units\":" << postTotals.attackerUnits << ","
|
||||
<< "\"post_defender_units\":" << postTotals.defenderUnits << ","
|
||||
<< "\"post_attacker_troops\":" << postTotals.attackerTroops << ","
|
||||
<< "\"post_defender_troops\":" << postTotals.defenderTroops << "}\n";
|
||||
}
|
||||
if (newState->current_round() > currentRound) {
|
||||
std::cout << "Round " << currentRound
|
||||
<< " completed. Commands this round: " << totalCommands << "\n";
|
||||
@@ -822,23 +428,7 @@ BattleResult AiBattleSimulator::RunBattlePhase(
|
||||
std::cout << "Battle phase complete. Total rounds: " << currentRound
|
||||
<< ", Total commands: " << totalCommands << "\n";
|
||||
|
||||
auto result =
|
||||
CreateResultFromGameState(engine.GetCurrentGameState(), currentRound, totalCommands);
|
||||
if (!engine.GameIsOver() && currentRound > harnessMaxRounds) {
|
||||
result.winner = DEFENDER_ID;
|
||||
result.totalRounds = harnessMaxRounds;
|
||||
result.endReason = BattleResult::EndReason::MAX_ROUNDS_REACHED;
|
||||
result.description = "Defender won by surviving maximum rounds";
|
||||
}
|
||||
result.attackerCommands = attackerCommands;
|
||||
result.defenderCommands = defenderCommands;
|
||||
result.attackerAverageDepth =
|
||||
attackerCommands == 0 ? 0.0
|
||||
: static_cast<double>(attackerDepthTotal) / attackerCommands;
|
||||
result.defenderAverageDepth =
|
||||
defenderCommands == 0 ? 0.0
|
||||
: static_cast<double>(defenderDepthTotal) / defenderCommands;
|
||||
return result;
|
||||
return CreateResultFromGameState(engine.GetCurrentGameState(), currentRound, totalCommands);
|
||||
}
|
||||
|
||||
bool AiBattleSimulator::IsGameOver(const GameStateW& state) const {
|
||||
@@ -865,10 +455,10 @@ BattleResult AiBattleSimulator::CreateResultFromGameState(
|
||||
if (winningIds && winningIds->size() > 0) {
|
||||
result.winner = winningIds->Get(0);
|
||||
|
||||
// Determine how the game ended
|
||||
if (result.winner == ATTACKER_ID) {
|
||||
if (status->end_game_condition()->victory_details() ==
|
||||
net::eagle0::shardok::storage::fb::
|
||||
VictoryCondition_VICTORY_CONDITION_HOLDS_CRITICAL_TILES) {
|
||||
// Attacker won
|
||||
if (totalRounds >= config_.max_rounds()) {
|
||||
result.endReason = BattleResult::EndReason::CRITICAL_TILES_CONTROLLED;
|
||||
result.description = "Attacker won by controlling critical tiles";
|
||||
} else {
|
||||
@@ -876,9 +466,8 @@ BattleResult AiBattleSimulator::CreateResultFromGameState(
|
||||
result.description = "Attacker won by eliminating all defenders";
|
||||
}
|
||||
} else if (result.winner == DEFENDER_ID) {
|
||||
if (status->end_game_condition()->victory_details() ==
|
||||
net::eagle0::shardok::storage::fb::
|
||||
VictoryCondition_VICTORY_CONDITION_WIN_AFTER_MAX_ROUNDS) {
|
||||
// Defender won
|
||||
if (totalRounds >= config_.max_rounds()) {
|
||||
result.endReason = BattleResult::EndReason::MAX_ROUNDS_REACHED;
|
||||
result.description = "Defender won by surviving maximum rounds";
|
||||
} else {
|
||||
@@ -900,11 +489,10 @@ BattleResult AiBattleSimulator::CreateResultFromGameState(
|
||||
result.endReason = BattleResult::EndReason::DRAW;
|
||||
result.description = "Game ended in a draw";
|
||||
} else {
|
||||
// If the harness stops before the engine posts its max-round status transition, Shardok's
|
||||
// battle rule still awards the battle to the defender.
|
||||
result.winner = DEFENDER_ID;
|
||||
result.endReason = BattleResult::EndReason::MAX_ROUNDS_REACHED;
|
||||
result.description = "Defender won by surviving maximum rounds";
|
||||
// Game not over yet (shouldn't happen)
|
||||
result.winner = -1;
|
||||
result.endReason = BattleResult::EndReason::DRAW;
|
||||
result.description = "Game incomplete";
|
||||
}
|
||||
|
||||
// Collect surviving units (for winner, or all units if draw)
|
||||
@@ -923,14 +511,6 @@ BattleResult AiBattleSimulator::CreateResultFromGameState(
|
||||
|
||||
if (!isAlive) { continue; }
|
||||
|
||||
if (unit->player_id() == ATTACKER_ID) {
|
||||
result.attackerSurvivingUnits++;
|
||||
result.attackerSurvivingTroops += unit->battalion().size();
|
||||
} else if (unit->player_id() == DEFENDER_ID) {
|
||||
result.defenderSurvivingUnits++;
|
||||
result.defenderSurvivingTroops += unit->battalion().size();
|
||||
}
|
||||
|
||||
// For victories, only include winner's units; for draws, include all
|
||||
if (result.winner != -1 && unit->player_id() != result.winner) { continue; }
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
#define EAGLE0_AI_BATTLE_SIMULATOR_HPP
|
||||
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <ostream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -53,20 +51,6 @@ struct BattleResult {
|
||||
// Total number of commands executed
|
||||
int totalCommands;
|
||||
|
||||
// AI-selected commands by player.
|
||||
int attackerCommands = 0;
|
||||
int defenderCommands = 0;
|
||||
|
||||
// Approximate average search depth reported by each AI.
|
||||
double attackerAverageDepth = 0.0;
|
||||
double defenderAverageDepth = 0.0;
|
||||
|
||||
// Surviving units and troop totals by side, useful for scoring A/B tests.
|
||||
int attackerSurvivingUnits = 0;
|
||||
int defenderSurvivingUnits = 0;
|
||||
double attackerSurvivingTroops = 0.0;
|
||||
double defenderSurvivingTroops = 0.0;
|
||||
|
||||
// How the game ended
|
||||
enum class EndReason {
|
||||
LAST_PLAYER_STANDING, // One player eliminated all opponent units
|
||||
@@ -83,13 +67,6 @@ struct BattleResult {
|
||||
std::vector<SurvivingUnit> survivingUnits;
|
||||
};
|
||||
|
||||
struct BattleTraceOptions {
|
||||
std::ostream* output = nullptr;
|
||||
std::string runLabel;
|
||||
std::string configPath;
|
||||
std::string statePath;
|
||||
};
|
||||
|
||||
class AiBattleSimulator {
|
||||
public:
|
||||
/**
|
||||
@@ -100,9 +77,7 @@ public:
|
||||
*/
|
||||
explicit AiBattleSimulator(
|
||||
const BattleConfigProto& config,
|
||||
GameSettingsSPtr gameSettings = nullptr,
|
||||
std::optional<GameStateW> initialGameState = std::nullopt,
|
||||
BattleTraceOptions traceOptions = {});
|
||||
GameSettingsSPtr gameSettings = nullptr);
|
||||
|
||||
/**
|
||||
* Run the battle from start to completion.
|
||||
@@ -115,13 +90,9 @@ public:
|
||||
[[nodiscard]] BattleResult RunBattle();
|
||||
|
||||
private:
|
||||
friend class AiBattleSimulatorTest_GeneratedUnitsMirrorProductionCapabilityFlags_Test;
|
||||
|
||||
// Configuration
|
||||
const BattleConfigProto config_;
|
||||
GameSettingsSPtr gameSettings_;
|
||||
std::optional<GameStateW> initialGameState_;
|
||||
BattleTraceOptions traceOptions_;
|
||||
|
||||
// Game state builder and helpers
|
||||
[[nodiscard]] GameStateW CreateInitialGameState() const;
|
||||
@@ -133,13 +104,8 @@ private:
|
||||
[[nodiscard]] BattleResult
|
||||
RunSetupPhase(ShardokEngine& engine, ShardokAIClient& attackerAI, ShardokAIClient& defenderAI);
|
||||
|
||||
[[nodiscard]] BattleResult RunConfiguredSetupPhase(ShardokEngine& engine);
|
||||
|
||||
[[nodiscard]] BattleResult RunBattlePhase(
|
||||
ShardokEngine& engine,
|
||||
ShardokAIClient& attackerAI,
|
||||
ShardokAIClient& defenderAI,
|
||||
int setupCommands);
|
||||
[[nodiscard]] BattleResult
|
||||
RunBattlePhase(ShardokEngine& engine, ShardokAIClient& attackerAI, ShardokAIClient& defenderAI);
|
||||
|
||||
[[nodiscard]] bool IsGameOver(const GameStateW& state) const;
|
||||
[[nodiscard]] BattleResult
|
||||
|
||||
@@ -21,7 +21,6 @@ cc_library(
|
||||
deps = [
|
||||
":ai_battle_config",
|
||||
"//src/main/cpp/net/eagle0/common:filesystem_utils",
|
||||
"//src/main/cpp/net/eagle0/common:random_generator",
|
||||
"//src/main/cpp/net/eagle0/common:tsv_parser",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:shardok_ai_client",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai_testing_common:ai_client_factory",
|
||||
@@ -29,7 +28,6 @@ cc_library(
|
||||
"//src/main/cpp/net/eagle0/shardok/ai_testing_common:game_settings_factory",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:engine",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:unit_placement_info",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/fb_helpers:game_state_helpers",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
|
||||
"//src/main/cpp/net/eagle0/shardok/util:battalion_type_registrar",
|
||||
@@ -44,7 +42,7 @@ cc_binary(
|
||||
name = "ai_battle_simulator_main",
|
||||
srcs = ["ai_battle_simulator_main.cpp"],
|
||||
copts = COPTS,
|
||||
data = glob(["benchmarks/*.json"]) + [
|
||||
data = [
|
||||
"//src/main/resources/net/eagle0/shardok:battalion_types",
|
||||
"//src/main/resources/net/eagle0/shardok:settings",
|
||||
"//src/main/resources/net/eagle0/shardok/maps",
|
||||
@@ -56,26 +54,3 @@ cc_binary(
|
||||
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:fixed_action_point_distances",
|
||||
],
|
||||
)
|
||||
|
||||
cc_binary(
|
||||
name = "real_battle_config_extractor",
|
||||
srcs = ["real_battle_config_extractor_main.cpp"],
|
||||
copts = COPTS + ["-Wno-deprecated-declarations"],
|
||||
deps = [
|
||||
":ai_battle_config",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_cc_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:shardok_battle_cc_proto",
|
||||
],
|
||||
)
|
||||
|
||||
cc_binary(
|
||||
name = "real_battle_smoke_metadata",
|
||||
srcs = ["real_battle_smoke_metadata_main.cpp"],
|
||||
copts = COPTS + ["-Wno-deprecated-declarations"],
|
||||
deps = [
|
||||
"//src/main/flatbuffer/net/eagle0/shardok/storage:game_state_cc_fbs",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:shardok_battle_cc_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/api:action_result_view_cc_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/storage:action_result_cc_proto",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -2,78 +2,37 @@
|
||||
// AI Battle Simulator - CLI Entry Point
|
||||
//
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "AiBattleConfig.hpp"
|
||||
#include "AiBattleSimulator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.hpp"
|
||||
|
||||
using shardok::GameStateW;
|
||||
using shardok::ai_battle_simulator::AiBattleConfigLoader;
|
||||
using shardok::ai_battle_simulator::AiBattleSimulator;
|
||||
using shardok::ai_battle_simulator::BattleResult;
|
||||
using shardok::ai_battle_simulator::BattleTraceOptions;
|
||||
|
||||
namespace {
|
||||
|
||||
enum class SummaryFormat {
|
||||
HUMAN,
|
||||
CSV,
|
||||
JSON,
|
||||
};
|
||||
|
||||
class NullBuffer : public std::streambuf {
|
||||
protected:
|
||||
int overflow(int c) override { return c; }
|
||||
};
|
||||
|
||||
void PrintUsage(const char* programName) {
|
||||
std::cout << "AI Battle Simulator - AI vs AI Battle Testing Tool\n"
|
||||
<< "\n"
|
||||
<< "Usage:\n"
|
||||
<< " " << programName << " --config=<path> Run battle from JSON config file\n"
|
||||
<< " " << programName << " --config=<path> --config=<path> --summary=csv --quiet\n"
|
||||
<< " " << programName
|
||||
<< " --config=<path> --state-file=<path> --summary=csv --quiet\n"
|
||||
<< " " << programName
|
||||
<< " --config=<path> --trace-jsonl=<path> --run-label=baseline\n"
|
||||
<< " " << programName << " --generate-config Generate sample config to stdout\n"
|
||||
<< " " << programName
|
||||
<< " --generate-config --output=<path> Generate sample config to file\n"
|
||||
<< " " << programName << " --help Show this help message\n"
|
||||
<< "\n"
|
||||
<< "Options:\n"
|
||||
<< " --summary=human|csv|json Output format for completed battle summaries\n"
|
||||
<< " --quiet Suppress simulator progress logs\n"
|
||||
<< " --trace-jsonl=<path> Write one JSON object per AI-selected command\n"
|
||||
<< " --run-label=<label> Label included in trace rows\n"
|
||||
<< "\n"
|
||||
<< "Examples:\n"
|
||||
<< " # Generate sample config\n"
|
||||
<< " " << programName << " --generate-config > my_battle.json\n"
|
||||
<< "\n"
|
||||
<< " # Run battle from config\n"
|
||||
<< " " << programName << " --config=my_battle.json\n"
|
||||
<< "\n"
|
||||
<< " # Run a compact benchmark suite\n"
|
||||
<< " " << programName
|
||||
<< " --config=baseline.json --config=candidate.json --summary=csv --quiet\n"
|
||||
<< "\n"
|
||||
<< " # Run an AI config from a saved Shardok GameState flatbuffer\n"
|
||||
<< " " << programName
|
||||
<< " --config=ai_config.json --state-file=state.fb --summary=csv --quiet\n"
|
||||
<< "\n";
|
||||
}
|
||||
|
||||
@@ -96,50 +55,23 @@ void GenerateConfigFile(const std::string& outputPath) {
|
||||
}
|
||||
}
|
||||
|
||||
std::string CsvEscape(const std::string& value) {
|
||||
bool mustQuote = false;
|
||||
std::string escaped;
|
||||
escaped.reserve(value.size());
|
||||
for (const char c : value) {
|
||||
if (c == '"' || c == ',' || c == '\n' || c == '\r') { mustQuote = true; }
|
||||
if (c == '"') { escaped.push_back('"'); }
|
||||
escaped.push_back(c);
|
||||
void RunBattleFromConfig(const std::string& configPath) {
|
||||
// Load config
|
||||
std::cout << "Loading config from: " << configPath << "\n";
|
||||
auto config = AiBattleConfigLoader::LoadFromJsonFile(configPath);
|
||||
|
||||
if (!config) {
|
||||
std::cerr << "Error: Failed to load config file\n";
|
||||
std::exit(1);
|
||||
}
|
||||
if (!mustQuote) { return escaped; }
|
||||
return "\"" + escaped + "\"";
|
||||
}
|
||||
|
||||
std::string JsonEscape(const std::string& value) {
|
||||
std::ostringstream out;
|
||||
for (const char c : value) {
|
||||
switch (c) {
|
||||
case '"': out << "\\\""; break;
|
||||
case '\\': out << "\\\\"; break;
|
||||
case '\n': out << "\\n"; break;
|
||||
case '\r': out << "\\r"; break;
|
||||
case '\t': out << "\\t"; break;
|
||||
default: out << c; break;
|
||||
}
|
||||
}
|
||||
return out.str();
|
||||
}
|
||||
// Create simulator
|
||||
AiBattleSimulator simulator(*config);
|
||||
|
||||
std::string WinnerLabel(int winner) {
|
||||
if (winner == 0) { return "attacker"; }
|
||||
if (winner == 1) { return "defender"; }
|
||||
return "draw";
|
||||
}
|
||||
// Run battle
|
||||
auto result = simulator.RunBattle();
|
||||
|
||||
std::string EndReasonLabel(BattleResult::EndReason endReason) {
|
||||
switch (endReason) {
|
||||
case BattleResult::EndReason::LAST_PLAYER_STANDING: return "last_player_standing";
|
||||
case BattleResult::EndReason::CRITICAL_TILES_CONTROLLED: return "critical_tiles_controlled";
|
||||
case BattleResult::EndReason::MAX_ROUNDS_REACHED: return "max_rounds_reached";
|
||||
case BattleResult::EndReason::DRAW: return "draw";
|
||||
}
|
||||
}
|
||||
|
||||
void PrintHumanSummary(const BattleResult& result) {
|
||||
// Print results
|
||||
std::cout << "\n";
|
||||
std::cout << "=================================\n";
|
||||
std::cout << "Battle Complete\n";
|
||||
@@ -155,17 +87,10 @@ void PrintHumanSummary(const BattleResult& result) {
|
||||
std::cout << "Result: " << result.description << "\n";
|
||||
std::cout << "Total Rounds: " << result.totalRounds << "\n";
|
||||
std::cout << "Total Commands: " << result.totalCommands << "\n";
|
||||
std::cout << "Attacker AI Commands: " << result.attackerCommands << "\n";
|
||||
std::cout << "Defender AI Commands: " << result.defenderCommands << "\n";
|
||||
std::cout << "Attacker Avg Depth: " << result.attackerAverageDepth << "\n";
|
||||
std::cout << "Defender Avg Depth: " << result.defenderAverageDepth << "\n";
|
||||
std::cout << "Attacker Survivors: " << result.attackerSurvivingUnits << " units, "
|
||||
<< result.attackerSurvivingTroops << " troops\n";
|
||||
std::cout << "Defender Survivors: " << result.defenderSurvivingUnits << " units, "
|
||||
<< result.defenderSurvivingTroops << " troops\n";
|
||||
|
||||
// Print surviving units
|
||||
if (!result.survivingUnits.empty()) {
|
||||
std::cout << "\nWinning/Draw Surviving Units (" << result.survivingUnits.size() << "):\n";
|
||||
std::cout << "\nSurviving Units (" << result.survivingUnits.size() << "):\n";
|
||||
for (const auto& unit : result.survivingUnits) {
|
||||
std::cout << " Unit " << unit.unitId << ": " << unit.battalionTypeName << " ("
|
||||
<< static_cast<int>(unit.battalionSize) << " troops)";
|
||||
@@ -177,143 +102,6 @@ void PrintHumanSummary(const BattleResult& result) {
|
||||
std::cout << "=================================\n";
|
||||
}
|
||||
|
||||
void PrintCsvHeader() {
|
||||
std::cout << "config,state_file,winner,end_reason,result,total_rounds,total_commands,"
|
||||
<< "attacker_ai_commands,defender_ai_commands,attacker_avg_depth,"
|
||||
<< "defender_avg_depth,attacker_surviving_units,defender_surviving_units,"
|
||||
<< "attacker_surviving_troops,defender_surviving_troops\n";
|
||||
}
|
||||
|
||||
void PrintCsvRow(
|
||||
const std::string& configPath,
|
||||
const std::string& statePath,
|
||||
const BattleResult& result) {
|
||||
std::cout << CsvEscape(configPath) << "," << CsvEscape(statePath) << ","
|
||||
<< WinnerLabel(result.winner) << "," << EndReasonLabel(result.endReason) << ","
|
||||
<< CsvEscape(result.description) << "," << result.totalRounds << ","
|
||||
<< result.totalCommands << "," << result.attackerCommands << ","
|
||||
<< result.defenderCommands << "," << result.attackerAverageDepth << ","
|
||||
<< result.defenderAverageDepth << "," << result.attackerSurvivingUnits << ","
|
||||
<< result.defenderSurvivingUnits << "," << result.attackerSurvivingTroops << ","
|
||||
<< result.defenderSurvivingTroops << "\n";
|
||||
}
|
||||
|
||||
void PrintJsonSummary(
|
||||
const std::string& configPath,
|
||||
const std::string& statePath,
|
||||
const BattleResult& result) {
|
||||
std::cout << "{"
|
||||
<< "\"config\":\"" << JsonEscape(configPath) << "\","
|
||||
<< "\"state_file\":\"" << JsonEscape(statePath) << "\","
|
||||
<< "\"winner\":\"" << WinnerLabel(result.winner) << "\","
|
||||
<< "\"end_reason\":\"" << EndReasonLabel(result.endReason) << "\","
|
||||
<< "\"result\":\"" << JsonEscape(result.description) << "\","
|
||||
<< "\"total_rounds\":" << result.totalRounds << ","
|
||||
<< "\"total_commands\":" << result.totalCommands << ","
|
||||
<< "\"attacker_ai_commands\":" << result.attackerCommands << ","
|
||||
<< "\"defender_ai_commands\":" << result.defenderCommands << ","
|
||||
<< "\"attacker_avg_depth\":" << result.attackerAverageDepth << ","
|
||||
<< "\"defender_avg_depth\":" << result.defenderAverageDepth << ","
|
||||
<< "\"attacker_surviving_units\":" << result.attackerSurvivingUnits << ","
|
||||
<< "\"defender_surviving_units\":" << result.defenderSurvivingUnits << ","
|
||||
<< "\"attacker_surviving_troops\":" << result.attackerSurvivingTroops << ","
|
||||
<< "\"defender_surviving_troops\":" << result.defenderSurvivingTroops << "}\n";
|
||||
}
|
||||
|
||||
std::unique_ptr<shardok::ai_battle_simulator::BattleConfigProto> LoadConfig(
|
||||
const std::string& configPath) {
|
||||
if (configPath.empty()) { return AiBattleConfigLoader::CreateDefaultPerfConfig(); }
|
||||
return AiBattleConfigLoader::LoadFromJsonFile(configPath);
|
||||
}
|
||||
|
||||
GameStateW LoadGameStateFromFile(const std::string& statePath) {
|
||||
if (statePath.empty()) { return GameStateW{}; }
|
||||
auto bytes = FilesystemUtils::LoadFromPath(statePath);
|
||||
if (bytes.empty()) { throw std::runtime_error("Failed to load state file: " + statePath); }
|
||||
return GameStateW::FromByteVector(bytes);
|
||||
}
|
||||
|
||||
BattleResult RunBattle(
|
||||
const std::string& configPath,
|
||||
const std::string& statePath,
|
||||
bool quiet,
|
||||
const std::string& tracePath,
|
||||
const std::string& runLabel) {
|
||||
std::ofstream traceFile;
|
||||
BattleTraceOptions traceOptions;
|
||||
if (!tracePath.empty()) {
|
||||
traceFile.open(tracePath);
|
||||
if (!traceFile.is_open()) {
|
||||
throw std::runtime_error("Failed to open trace file: " + tracePath);
|
||||
}
|
||||
traceOptions.output = &traceFile;
|
||||
traceOptions.runLabel = runLabel;
|
||||
traceOptions.configPath = configPath;
|
||||
traceOptions.statePath = statePath;
|
||||
}
|
||||
|
||||
NullBuffer nullBuffer;
|
||||
std::ostream nullStream(&nullBuffer);
|
||||
auto* originalCout = std::cout.rdbuf();
|
||||
auto* originalCerr = std::cerr.rdbuf();
|
||||
int stdoutCopy = -1;
|
||||
int stderrCopy = -1;
|
||||
if (quiet) {
|
||||
std::cout.rdbuf(nullStream.rdbuf());
|
||||
std::cerr.rdbuf(nullStream.rdbuf());
|
||||
std::fflush(stdout);
|
||||
std::fflush(stderr);
|
||||
stdoutCopy = dup(STDOUT_FILENO);
|
||||
stderrCopy = dup(STDERR_FILENO);
|
||||
const int nullFd = open("/dev/null", O_WRONLY);
|
||||
if (nullFd >= 0) {
|
||||
dup2(nullFd, STDOUT_FILENO);
|
||||
dup2(nullFd, STDERR_FILENO);
|
||||
close(nullFd);
|
||||
}
|
||||
}
|
||||
|
||||
auto restoreOutput = [&]() {
|
||||
if (!quiet) { return; }
|
||||
std::fflush(stdout);
|
||||
std::fflush(stderr);
|
||||
if (stdoutCopy >= 0) {
|
||||
dup2(stdoutCopy, STDOUT_FILENO);
|
||||
close(stdoutCopy);
|
||||
}
|
||||
if (stderrCopy >= 0) {
|
||||
dup2(stderrCopy, STDERR_FILENO);
|
||||
close(stderrCopy);
|
||||
}
|
||||
std::cout.rdbuf(originalCout);
|
||||
std::cerr.rdbuf(originalCerr);
|
||||
};
|
||||
|
||||
// Load config
|
||||
if (!configPath.empty()) { std::cout << "Loading config from: " << configPath << "\n"; }
|
||||
auto config = LoadConfig(configPath);
|
||||
|
||||
if (!config) {
|
||||
restoreOutput();
|
||||
std::cerr << "Error: Failed to load config file\n";
|
||||
std::exit(1);
|
||||
}
|
||||
|
||||
// Create simulator
|
||||
std::optional<GameStateW> initialGameState;
|
||||
if (!statePath.empty()) {
|
||||
std::cout << "Loading saved state from: " << statePath << "\n";
|
||||
initialGameState = LoadGameStateFromFile(statePath);
|
||||
}
|
||||
AiBattleSimulator simulator(*config, nullptr, std::move(initialGameState), traceOptions);
|
||||
|
||||
// Run battle
|
||||
auto result = simulator.RunBattle();
|
||||
|
||||
restoreOutput();
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
@@ -326,14 +114,9 @@ int main(int argc, char* argv[]) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::vector<std::string> configPaths;
|
||||
std::vector<std::string> statePaths;
|
||||
std::string configPath;
|
||||
std::string outputPath;
|
||||
std::string tracePath;
|
||||
std::string runLabel;
|
||||
bool generateConfig = false;
|
||||
bool quiet = false;
|
||||
SummaryFormat summaryFormat = SummaryFormat::HUMAN;
|
||||
|
||||
// Parse command line arguments
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
@@ -345,30 +128,9 @@ int main(int argc, char* argv[]) {
|
||||
} else if (arg == "--generate-config") {
|
||||
generateConfig = true;
|
||||
} else if (arg.starts_with("--config=")) {
|
||||
configPaths.push_back(arg.substr(9));
|
||||
} else if (arg.starts_with("--state-file=")) {
|
||||
statePaths.push_back(arg.substr(13));
|
||||
configPath = arg.substr(9);
|
||||
} else if (arg.starts_with("--output=")) {
|
||||
outputPath = arg.substr(9);
|
||||
} else if (arg.starts_with("--trace-jsonl=")) {
|
||||
tracePath = arg.substr(14);
|
||||
} else if (arg.starts_with("--run-label=")) {
|
||||
runLabel = arg.substr(12);
|
||||
} else if (arg == "--quiet") {
|
||||
quiet = true;
|
||||
} else if (arg.starts_with("--summary=")) {
|
||||
const auto format = arg.substr(10);
|
||||
if (format == "human") {
|
||||
summaryFormat = SummaryFormat::HUMAN;
|
||||
} else if (format == "csv") {
|
||||
summaryFormat = SummaryFormat::CSV;
|
||||
} else if (format == "json") {
|
||||
summaryFormat = SummaryFormat::JSON;
|
||||
} else {
|
||||
std::cerr << "Error: Unknown summary format: " << format << "\n";
|
||||
PrintUsage(argv[0]);
|
||||
return 1;
|
||||
}
|
||||
} else {
|
||||
std::cerr << "Error: Unknown argument: " << arg << "\n";
|
||||
PrintUsage(argv[0]);
|
||||
@@ -379,25 +141,10 @@ int main(int argc, char* argv[]) {
|
||||
// Execute appropriate action
|
||||
if (generateConfig) {
|
||||
GenerateConfigFile(outputPath);
|
||||
} else if (!configPaths.empty() || !statePaths.empty()) {
|
||||
if (summaryFormat == SummaryFormat::CSV) { PrintCsvHeader(); }
|
||||
if (configPaths.empty()) { configPaths.emplace_back(""); }
|
||||
if (statePaths.empty()) { statePaths.emplace_back(""); }
|
||||
for (const auto& configPath : configPaths) {
|
||||
for (const auto& statePath : statePaths) {
|
||||
const auto result =
|
||||
RunBattle(configPath, statePath, quiet, tracePath, runLabel);
|
||||
switch (summaryFormat) {
|
||||
case SummaryFormat::HUMAN: PrintHumanSummary(result); break;
|
||||
case SummaryFormat::CSV: PrintCsvRow(configPath, statePath, result); break;
|
||||
case SummaryFormat::JSON:
|
||||
PrintJsonSummary(configPath, statePath, result);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!configPath.empty()) {
|
||||
RunBattleFromConfig(configPath);
|
||||
} else {
|
||||
std::cerr << "Error: --generate-config, --config, or --state-file must be specified\n";
|
||||
std::cerr << "Error: Either --generate-config or --config must be specified\n";
|
||||
PrintUsage(argv[0]);
|
||||
return 1;
|
||||
}
|
||||
|
||||
-307
@@ -1,307 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare paired Shardok AI simulator traces for one or more battles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
STALL_COMMANDS = {
|
||||
"END_TURN_COMMAND",
|
||||
"FLEE_COMMAND",
|
||||
"MOVE_COMMAND",
|
||||
"UNIT_REST_COMMAND",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TraceEvent:
|
||||
run_label: str
|
||||
phase: str
|
||||
sequence: int
|
||||
round: int
|
||||
side: str
|
||||
player: int
|
||||
command_type: str
|
||||
actor_unit: int
|
||||
available_command_count: int
|
||||
depth_achieved: int
|
||||
completion_reason: str
|
||||
forced_commands: int
|
||||
pre_attacker_troops: int
|
||||
pre_defender_troops: int
|
||||
post_attacker_troops: int
|
||||
post_defender_troops: int
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, row: dict[str, object]) -> TraceEvent:
|
||||
return cls(
|
||||
run_label=str(row["run_label"]),
|
||||
phase=str(row["phase"]),
|
||||
sequence=int(row["sequence"]),
|
||||
round=int(row["round"]),
|
||||
side=str(row["side"]),
|
||||
player=int(row["player"]),
|
||||
command_type=str(row["command_type"]),
|
||||
actor_unit=int(row["actor_unit"]),
|
||||
available_command_count=int(row["available_command_count"]),
|
||||
depth_achieved=int(row["depth_achieved"]),
|
||||
completion_reason=str(row["completion_reason"]),
|
||||
forced_commands=int(row["forced_commands"]),
|
||||
pre_attacker_troops=int(row["pre_attacker_troops"]),
|
||||
pre_defender_troops=int(row["pre_defender_troops"]),
|
||||
post_attacker_troops=int(row["post_attacker_troops"]),
|
||||
post_defender_troops=int(row["post_defender_troops"]),
|
||||
)
|
||||
|
||||
@property
|
||||
def attacker_delta(self) -> int:
|
||||
return self.post_attacker_troops - self.pre_attacker_troops
|
||||
|
||||
@property
|
||||
def defender_delta(self) -> int:
|
||||
return self.post_defender_troops - self.pre_defender_troops
|
||||
|
||||
@property
|
||||
def did_damage(self) -> bool:
|
||||
return self.attacker_delta < 0 or self.defender_delta < 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoundSummary:
|
||||
battle_round: int
|
||||
attacker_commands: int
|
||||
defender_commands: int
|
||||
attacker_damage: int
|
||||
defender_damage: int
|
||||
attacker_command_counts: Counter[str]
|
||||
defender_command_counts: Counter[str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunSummary:
|
||||
label: str
|
||||
final_attacker_troops: int
|
||||
final_defender_troops: int
|
||||
command_counts: Counter[str]
|
||||
side_command_counts: dict[str, Counter[str]]
|
||||
side_damage_done: dict[str, int]
|
||||
no_damage_streaks: list[list[TraceEvent]]
|
||||
rounds: dict[int, RoundSummary]
|
||||
|
||||
|
||||
def load_trace(path: Path) -> list[TraceEvent]:
|
||||
events = []
|
||||
with path.open() as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
events.append(TraceEvent.from_json(json.loads(line)))
|
||||
return events
|
||||
|
||||
|
||||
def battle_events(events: Iterable[TraceEvent]) -> list[TraceEvent]:
|
||||
return [event for event in events if event.phase == "battle"]
|
||||
|
||||
|
||||
def side_damage(event: TraceEvent) -> int:
|
||||
if event.side == "attacker":
|
||||
return max(0, -event.defender_delta)
|
||||
return max(0, -event.attacker_delta)
|
||||
|
||||
|
||||
def longest_no_damage_streaks(events: list[TraceEvent], limit: int) -> list[list[TraceEvent]]:
|
||||
streaks = []
|
||||
current = []
|
||||
for event in events:
|
||||
if event.command_type in STALL_COMMANDS and not event.did_damage:
|
||||
current.append(event)
|
||||
continue
|
||||
if current:
|
||||
streaks.append(current)
|
||||
current = []
|
||||
if current:
|
||||
streaks.append(current)
|
||||
streaks.sort(key=len, reverse=True)
|
||||
return streaks[:limit]
|
||||
|
||||
|
||||
def summarize_run(label: str, events: list[TraceEvent], streak_limit: int) -> RunSummary:
|
||||
battle = battle_events(events)
|
||||
if not battle:
|
||||
raise RuntimeError(f"No battle events in {label}")
|
||||
|
||||
command_counts = Counter(event.command_type for event in battle)
|
||||
side_command_counts: dict[str, Counter[str]] = {
|
||||
"attacker": Counter(),
|
||||
"defender": Counter(),
|
||||
}
|
||||
side_damage_done = {
|
||||
"attacker": 0,
|
||||
"defender": 0,
|
||||
}
|
||||
per_round: dict[int, list[TraceEvent]] = defaultdict(list)
|
||||
for event in battle:
|
||||
side_command_counts[event.side][event.command_type] += 1
|
||||
side_damage_done[event.side] += side_damage(event)
|
||||
per_round[event.round].append(event)
|
||||
|
||||
rounds = {}
|
||||
for battle_round, round_events in sorted(per_round.items()):
|
||||
attacker_events = [event for event in round_events if event.side == "attacker"]
|
||||
defender_events = [event for event in round_events if event.side == "defender"]
|
||||
rounds[battle_round] = RoundSummary(
|
||||
battle_round=battle_round,
|
||||
attacker_commands=len(attacker_events),
|
||||
defender_commands=len(defender_events),
|
||||
attacker_damage=sum(side_damage(event) for event in attacker_events),
|
||||
defender_damage=sum(side_damage(event) for event in defender_events),
|
||||
attacker_command_counts=Counter(event.command_type for event in attacker_events),
|
||||
defender_command_counts=Counter(event.command_type for event in defender_events),
|
||||
)
|
||||
|
||||
final = battle[-1]
|
||||
return RunSummary(
|
||||
label=label,
|
||||
final_attacker_troops=final.post_attacker_troops,
|
||||
final_defender_troops=final.post_defender_troops,
|
||||
command_counts=command_counts,
|
||||
side_command_counts=side_command_counts,
|
||||
side_damage_done=side_damage_done,
|
||||
no_damage_streaks=longest_no_damage_streaks(battle, streak_limit),
|
||||
rounds=rounds,
|
||||
)
|
||||
|
||||
|
||||
def command_counts_text(counts: Counter[str]) -> str:
|
||||
if not counts:
|
||||
return ""
|
||||
return ", ".join(f"{name.removesuffix('_COMMAND')} {count}" for name, count in counts.most_common())
|
||||
|
||||
|
||||
def format_streak(streak: list[TraceEvent]) -> str:
|
||||
first = streak[0]
|
||||
last = streak[-1]
|
||||
command_counts = Counter(event.command_type for event in streak)
|
||||
return (
|
||||
f"rounds {first.round}-{last.round}, seq {first.sequence}-{last.sequence}, "
|
||||
f"{len(streak)} commands: {command_counts_text(command_counts)}"
|
||||
)
|
||||
|
||||
|
||||
def write_battle_report(
|
||||
lines: list[str],
|
||||
battle: int,
|
||||
baseline: RunSummary,
|
||||
candidate: RunSummary,
|
||||
) -> None:
|
||||
lines.extend(
|
||||
[
|
||||
f"## Battle {battle}",
|
||||
"",
|
||||
"| Run | Final troops | Attacker damage | Defender damage | Attacker commands | Defender commands |",
|
||||
"| --- | ---: | ---: | ---: | --- | --- |",
|
||||
]
|
||||
)
|
||||
for summary in [baseline, candidate]:
|
||||
lines.append(
|
||||
f"| {summary.label} | {summary.final_attacker_troops} vs {summary.final_defender_troops} | "
|
||||
f"{summary.side_damage_done['attacker']} | {summary.side_damage_done['defender']} | "
|
||||
f"{command_counts_text(summary.side_command_counts['attacker'])} | "
|
||||
f"{command_counts_text(summary.side_command_counts['defender'])} |"
|
||||
)
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"### Per-Round Differences",
|
||||
"",
|
||||
"| Round | Baseline attacker | Candidate attacker | Baseline defender | Candidate defender |",
|
||||
"| ---: | --- | --- | --- | --- |",
|
||||
]
|
||||
)
|
||||
all_rounds = sorted(set(baseline.rounds) | set(candidate.rounds))
|
||||
for battle_round in all_rounds:
|
||||
baseline_round = baseline.rounds.get(battle_round)
|
||||
candidate_round = candidate.rounds.get(battle_round)
|
||||
baseline_attacker = round_side_text(baseline_round, "attacker")
|
||||
candidate_attacker = round_side_text(candidate_round, "attacker")
|
||||
baseline_defender = round_side_text(baseline_round, "defender")
|
||||
candidate_defender = round_side_text(candidate_round, "defender")
|
||||
if baseline_attacker == candidate_attacker and baseline_defender == candidate_defender:
|
||||
continue
|
||||
lines.append(
|
||||
f"| {battle_round} | {baseline_attacker} | {candidate_attacker} | "
|
||||
f"{baseline_defender} | {candidate_defender} |"
|
||||
)
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"### Longest No-Damage Streaks",
|
||||
"",
|
||||
"| Run | Streak |",
|
||||
"| --- | --- |",
|
||||
]
|
||||
)
|
||||
for summary in [baseline, candidate]:
|
||||
if not summary.no_damage_streaks:
|
||||
lines.append(f"| {summary.label} | _none_ |")
|
||||
continue
|
||||
for streak in summary.no_damage_streaks:
|
||||
lines.append(f"| {summary.label} | {format_streak(streak)} |")
|
||||
lines.append("")
|
||||
|
||||
|
||||
def round_side_text(summary: RoundSummary | None, side: str) -> str:
|
||||
if summary is None:
|
||||
return "_none_"
|
||||
if side == "attacker":
|
||||
damage = summary.attacker_damage
|
||||
counts = summary.attacker_command_counts
|
||||
commands = summary.attacker_commands
|
||||
else:
|
||||
damage = summary.defender_damage
|
||||
counts = summary.defender_command_counts
|
||||
commands = summary.defender_commands
|
||||
return f"{commands} cmds, {damage} dmg ({command_counts_text(counts)})"
|
||||
|
||||
|
||||
def parse_battles(value: str) -> list[int]:
|
||||
return [int(item.strip()) for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--experiment-dir", type=Path, required=True)
|
||||
parser.add_argument("--battles", required=True, help="Comma-separated action_seq values")
|
||||
parser.add_argument("--out", type=Path, required=True)
|
||||
parser.add_argument("--streak-limit", type=int, default=4)
|
||||
args = parser.parse_args()
|
||||
|
||||
lines = [
|
||||
"# Trace Comparison",
|
||||
"",
|
||||
f"- Experiment directory: `{args.experiment_dir}`",
|
||||
f"- Battles: `{args.battles}`",
|
||||
"",
|
||||
]
|
||||
for battle in parse_battles(args.battles):
|
||||
trace_dir = args.experiment_dir / "traces"
|
||||
baseline_events = load_trace(trace_dir / f"{battle}_baseline.jsonl")
|
||||
candidate_events = load_trace(trace_dir / f"{battle}_candidate.jsonl")
|
||||
baseline = summarize_run("baseline", baseline_events, args.streak_limit)
|
||||
candidate = summarize_run("candidate", candidate_events, args.streak_limit)
|
||||
write_battle_report(lines, battle, baseline, candidate)
|
||||
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.out.write_text("\n".join(lines))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
action_seq,shardok_game_id,attacker_factions,defender_factions,real_winner,sim_winner,winner_match,real_round,sim_round,real_attacker_troops,real_defender_troops,sim_attacker_troops,sim_defender_troops
|
||||
306,389ffb1901903118_1_b3e7d8ab,1,2,attacker,attacker,true,0,19,1454,0,1388,0
|
||||
307,389ffb1901903118_2_bf499d3c,2,1,defender,defender,true,0,16,0,699,0,648
|
||||
399,389ffb1901903118_3_cd9141a,2,1,defender,defender,true,0,6,0,1310,0,1315
|
||||
631,389ffb1901903118_4_6e7b9b,1,2,defender,defender,true,0,5,0,3766,0,3654
|
||||
1326,389ffb1901903118_5_8a57255a,9,6,attacker,attacker,true,0,11,1715,0,1701,0
|
||||
2437,389ffb1901903118_7_5cf74e0,2,1,attacker,attacker,true,0,16,3146,0,3247,0
|
||||
2584,389ffb1901903118_8_41ce0ebd,1,2,defender,defender,true,0,31,0,2028,0,168
|
||||
2654,389ffb1901903118_9_eb43d421,2,1,defender,defender,true,0,28,0,335,0,467
|
||||
3536,389ffb1901903118_a_950de1c1,1,2,attacker,attacker,true,0,12,2172,0,2528,0
|
||||
3701,389ffb1901903118_b_5d1d76e0,8,2,defender,defender,true,0,30,0,2819,0,1348
|
||||
4298,389ffb1901903118_c_7bdb54dd,2,1,defender,defender,true,0,3,0,3514,0,1977
|
||||
4576,389ffb1901903118_d_689ee2fa,2,1,defender,defender,true,0,4,0,2028,0,1986
|
||||
6014,389ffb1901903118_e_89c90dc6,2,8,defender,defender,true,0,6,0,1942,0,2252
|
||||
6248,389ffb1901903118_f_3a9d9136,7,9,attacker,attacker,true,0,25,520,0,676,0
|
||||
6705,389ffb1901903118_11_e0611233,1,2,defender,defender,true,0,31,0,3316,0,535
|
||||
7212,389ffb1901903118_12_84a36154,2,8,defender,defender,true,0,8,0,1253,0,1273
|
||||
8217,389ffb1901903118_13_2e98b1cf,8,2,attacker,attacker,true,0,22,920,0,2167,0
|
||||
8394,389ffb1901903118_14_b5570863,2,8,attacker,attacker,true,0,9,2449,0,3186,0
|
||||
9975,389ffb1901903118_16_3d6ec341,2,1,defender,defender,true,0,4,0,7420,0,7840
|
||||
10229,389ffb1901903118_17_3ba689e3,5,7,attacker,attacker,true,0,23,2135,0,1816,0
|
||||
10631,389ffb1901903118_18_9e463db3,1,2,attacker,attacker,true,0,1,4000,0,4000,0
|
||||
11011,389ffb1901903118_19_239725d3,2,1,defender,defender,true,0,5,0,3532,0,3658
|
||||
11012,389ffb1901903118_1a_16e1ebf,9,6,defender,defender,true,0,30,0,846,0,1347
|
||||
11297,389ffb1901903118_1c_529ba6e5,1,2,attacker,attacker,true,0,1,2400,0,2400,0
|
||||
11469,389ffb1901903118_1d_49993012,5,9,defender,defender,true,0,8,0,787,0,1489
|
||||
11470,389ffb1901903118_1e_fd8c60a8,7,5,attacker,attacker,true,0,17,3220,0,3425,0
|
||||
11565,389ffb1901903118_1f_53309484,5,7,defender,defender,true,0,31,190,3420,78,3600
|
||||
12136,389ffb1901903118_24_f0d61a5a,9,7,attacker,attacker,true,0,21,1829,0,1581,0
|
||||
12137,389ffb1901903118_25_a19c4d87,2,1,defender,defender,true,0,8,0,1894,0,1892
|
||||
12765,389ffb1901903118_27_c008989b,6,9,defender,defender,true,0,11,0,1773,0,1763
|
||||
13752,389ffb1901903118_28_b33e2868,2,1,defender,defender,true,0,7,0,943,0,932
|
||||
|
-45
@@ -1,45 +0,0 @@
|
||||
# AI-vs-AI Real Battle Smoke Test
|
||||
|
||||
This smoke test reruns saved battles where neither side used faction `3` or `4`,
|
||||
then compares STANDARD-vs-STANDARD winner side with the winner side recorded by
|
||||
the original Shardok battle. It is intentionally a broad similarity check rather
|
||||
than an exact replay assertion.
|
||||
|
||||
- Checked battles: 31
|
||||
- Winner-side matches: 31
|
||||
- Match rate: 100%
|
||||
- Required match rate: 75%
|
||||
|
||||
| Action seq | Factions | Real winner | Sim winner | Real troops | Sim troops |
|
||||
| ---: | --- | --- | --- | ---: | ---: |
|
||||
| 306 | 1 vs 2 | attacker | attacker | 1454 vs 0 | 1388 vs 0 |
|
||||
| 307 | 2 vs 1 | defender | defender | 0 vs 699 | 0 vs 648 |
|
||||
| 399 | 2 vs 1 | defender | defender | 0 vs 1310 | 0 vs 1315 |
|
||||
| 631 | 1 vs 2 | defender | defender | 0 vs 3766 | 0 vs 3654 |
|
||||
| 1326 | 9 vs 6 | attacker | attacker | 1715 vs 0 | 1701 vs 0 |
|
||||
| 2437 | 2 vs 1 | attacker | attacker | 3146 vs 0 | 3247 vs 0 |
|
||||
| 2584 | 1 vs 2 | defender | defender | 0 vs 2028 | 0 vs 168 |
|
||||
| 2654 | 2 vs 1 | defender | defender | 0 vs 335 | 0 vs 467 |
|
||||
| 3536 | 1 vs 2 | attacker | attacker | 2172 vs 0 | 2528 vs 0 |
|
||||
| 3701 | 8 vs 2 | defender | defender | 0 vs 2819 | 0 vs 1348 |
|
||||
| 4298 | 2 vs 1 | defender | defender | 0 vs 3514 | 0 vs 1977 |
|
||||
| 4576 | 2 vs 1 | defender | defender | 0 vs 2028 | 0 vs 1986 |
|
||||
| 6014 | 2 vs 8 | defender | defender | 0 vs 1942 | 0 vs 2252 |
|
||||
| 6248 | 7 vs 9 | attacker | attacker | 520 vs 0 | 676 vs 0 |
|
||||
| 6705 | 1 vs 2 | defender | defender | 0 vs 3316 | 0 vs 535 |
|
||||
| 7212 | 2 vs 8 | defender | defender | 0 vs 1253 | 0 vs 1273 |
|
||||
| 8217 | 8 vs 2 | attacker | attacker | 920 vs 0 | 2167 vs 0 |
|
||||
| 8394 | 2 vs 8 | attacker | attacker | 2449 vs 0 | 3186 vs 0 |
|
||||
| 9975 | 2 vs 1 | defender | defender | 0 vs 7420 | 0 vs 7840 |
|
||||
| 10229 | 5 vs 7 | attacker | attacker | 2135 vs 0 | 1816 vs 0 |
|
||||
| 10631 | 1 vs 2 | attacker | attacker | 4000 vs 0 | 4000 vs 0 |
|
||||
| 11011 | 2 vs 1 | defender | defender | 0 vs 3532 | 0 vs 3658 |
|
||||
| 11012 | 9 vs 6 | defender | defender | 0 vs 846 | 0 vs 1347 |
|
||||
| 11297 | 1 vs 2 | attacker | attacker | 2400 vs 0 | 2400 vs 0 |
|
||||
| 11469 | 5 vs 9 | defender | defender | 0 vs 787 | 0 vs 1489 |
|
||||
| 11470 | 7 vs 5 | attacker | attacker | 3220 vs 0 | 3425 vs 0 |
|
||||
| 11565 | 5 vs 7 | defender | defender | 190 vs 3420 | 78 vs 3600 |
|
||||
| 12136 | 9 vs 7 | attacker | attacker | 1829 vs 0 | 1581 vs 0 |
|
||||
| 12137 | 2 vs 1 | defender | defender | 0 vs 1894 | 0 vs 1892 |
|
||||
| 12765 | 6 vs 9 | defender | defender | 0 vs 1773 | 0 vs 1763 |
|
||||
| 13752 | 2 vs 1 | defender | defender | 0 vs 943 | 0 vs 932 |
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
variant,action_seq,config,winner,end_reason,result,total_rounds,total_commands,attacker_ai_commands,defender_ai_commands,attacker_avg_depth,defender_avg_depth,attacker_surviving_units,defender_surviving_units,attacker_surviving_troops,defender_surviving_troops,elapsed_seconds
|
||||
expected_impact_archery_standard_baseline,306,/private/tmp/eagle0-standard-baselines/configs/306_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,19,99,41,32,4.04878,3.65625,2,0,1388,0,
|
||||
expected_impact_archery_standard_baseline,307,/private/tmp/eagle0-standard-baselines/configs/307_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,16,75,37,15,3.54054,2.86667,0,1,0,648,
|
||||
expected_impact_archery_standard_baseline,399,/private/tmp/eagle0-standard-baselines/configs/399_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,6,30,7,14,3,3.85714,0,3,0,1315,
|
||||
expected_impact_archery_standard_baseline,631,/private/tmp/eagle0-standard-baselines/configs/631_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,5,38,12,14,3.58333,4.14286,0,5,0,3654,
|
||||
expected_impact_archery_standard_baseline,1326,/private/tmp/eagle0-standard-baselines/configs/1326_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,11,72,34,19,3.26471,3.63158,2,0,1701,0,
|
||||
expected_impact_archery_standard_baseline,1472,/private/tmp/eagle0-standard-baselines/configs/1472_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,31,143,59,67,3.25424,3.46269,0,2,0,944,
|
||||
expected_impact_archery_standard_baseline,2437,/private/tmp/eagle0-standard-baselines/configs/2437_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,16,148,89,43,3.37079,3.32558,5,0,3247,0,
|
||||
expected_impact_archery_standard_baseline,2584,/private/tmp/eagle0-standard-baselines/configs/2584_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,31,190,48,85,3.45833,3.16471,0,1,0,168,
|
||||
expected_impact_archery_standard_baseline,2654,/private/tmp/eagle0-standard-baselines/configs/2654_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,28,198,78,72,3.19231,4.04167,0,2,0,467,
|
||||
expected_impact_archery_standard_baseline,3536,/private/tmp/eagle0-standard-baselines/configs/3536_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,12,120,57,36,3.40351,2.88889,4,0,2528,0,
|
||||
expected_impact_archery_standard_baseline,3701,/private/tmp/eagle0-standard-baselines/configs/3701_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,30,155,62,70,3.5,3.24286,0,4,0,1348,
|
||||
expected_impact_archery_standard_baseline,4298,/private/tmp/eagle0-standard-baselines/configs/4298_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,3,38,13,11,3.07692,2.90909,0,6,0,1977,
|
||||
expected_impact_archery_standard_baseline,4576,/private/tmp/eagle0-standard-baselines/configs/4576_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,4,35,17,7,3,3.28571,0,3,0,1986,
|
||||
expected_impact_archery_standard_baseline,6014,/private/tmp/eagle0-standard-baselines/configs/6014_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,6,39,20,7,3.15,3.42857,0,3,0,2252,
|
||||
expected_impact_archery_standard_baseline,6248,/private/tmp/eagle0-standard-baselines/configs/6248_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,25,153,75,46,3.65333,2.8913,2,0,676,0,
|
||||
expected_impact_archery_standard_baseline,6408,/private/tmp/eagle0-standard-baselines/configs/6408_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,11,107,68,18,3.5,3.33333,5,0,4020,0,
|
||||
expected_impact_archery_standard_baseline,6705,/private/tmp/eagle0-standard-baselines/configs/6705_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,31,205,87,86,3.01149,3.74419,0,2,0,535,
|
||||
expected_impact_archery_standard_baseline,7212,/private/tmp/eagle0-standard-baselines/configs/7212_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,8,39,18,11,3.38889,3.63636,0,2,0,1273,
|
||||
expected_impact_archery_standard_baseline,8217,/private/tmp/eagle0-standard-baselines/configs/8217_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,22,164,79,49,3.35443,3.08163,3,0,2167,0,
|
||||
expected_impact_archery_standard_baseline,8394,/private/tmp/eagle0-standard-baselines/configs/8394_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,9,81,48,17,3.25,2.64706,4,0,3186,0,
|
||||
expected_impact_archery_standard_baseline,8628,/private/tmp/eagle0-standard-baselines/configs/8628_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,5,41,27,3,2.96296,2,4,0,2760,0,
|
||||
expected_impact_archery_standard_baseline,9975,/private/tmp/eagle0-standard-baselines/configs/9975_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,4,70,23,27,2.65217,2.40741,0,10,0,7840,
|
||||
expected_impact_archery_standard_baseline,10229,/private/tmp/eagle0-standard-baselines/configs/10229_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,23,104,57,4,3.77193,3,3,0,1816,0,
|
||||
expected_impact_archery_standard_baseline,10631,/private/tmp/eagle0-standard-baselines/configs/10631_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,1,16,7,1,3,3,5,1,4000,0,
|
||||
expected_impact_archery_standard_baseline,11011,/private/tmp/eagle0-standard-baselines/configs/11011_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,5,51,22,18,3.5,3,0,5,0,3658,
|
||||
expected_impact_archery_standard_baseline,11012,/private/tmp/eagle0-standard-baselines/configs/11012_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,30,146,47,67,3.40426,3.62687,0,3,0,1347,
|
||||
expected_impact_archery_standard_baseline,11112,/private/tmp/eagle0-standard-baselines/configs/11112_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,3,31,7,8,3.42857,2,0,11,0,7595,
|
||||
expected_impact_archery_standard_baseline,11297,/private/tmp/eagle0-standard-baselines/configs/11297_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,1,13,5,1,3,3,3,1,2400,0,
|
||||
expected_impact_archery_standard_baseline,11469,/private/tmp/eagle0-standard-baselines/configs/11469_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,8,58,30,12,3.16667,3.16667,0,2,0,1489,
|
||||
expected_impact_archery_standard_baseline,11470,/private/tmp/eagle0-standard-baselines/configs/11470_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,17,99,65,22,3.50769,2.68182,4,0,3425,0,
|
||||
expected_impact_archery_standard_baseline,11565,/private/tmp/eagle0-standard-baselines/configs/11565_standard.json,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,103,30,34,4.26667,3.91176,1,4,78,3600,
|
||||
expected_impact_archery_standard_baseline,11662,/private/tmp/eagle0-standard-baselines/configs/11662_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,8,120,85,17,2.85882,3.23529,9,0,6624,0,
|
||||
expected_impact_archery_standard_baseline,11754,/private/tmp/eagle0-standard-baselines/configs/11754_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,30,163,72,76,3.31944,3.28947,0,3,0,1000,
|
||||
expected_impact_archery_standard_baseline,11941,/private/tmp/eagle0-standard-baselines/configs/11941_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,6,64,44,7,3.25,3,6,0,4777,0,
|
||||
expected_impact_archery_standard_baseline,11942,/private/tmp/eagle0-standard-baselines/configs/11942_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,5,91,59,16,2.64407,3.375,12,0,8572,0,
|
||||
expected_impact_archery_standard_baseline,12136,/private/tmp/eagle0-standard-baselines/configs/12136_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,21,141,79,34,3.70886,4.52941,3,0,1581,0,
|
||||
expected_impact_archery_standard_baseline,12137,/private/tmp/eagle0-standard-baselines/configs/12137_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,8,50,19,17,3.21053,3.29412,0,3,0,1892,
|
||||
expected_impact_archery_standard_baseline,12138,/private/tmp/eagle0-standard-baselines/configs/12138_standard.json,attacker,last_player_standing,Attacker won by eliminating all defenders,14,102,69,16,3.43478,2.9375,3,0,2512,0,
|
||||
expected_impact_archery_standard_baseline,12765,/private/tmp/eagle0-standard-baselines/configs/12765_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,11,46,26,11,3.61538,3.09091,0,2,0,1763,
|
||||
expected_impact_archery_standard_baseline,13752,/private/tmp/eagle0-standard-baselines/configs/13752_standard.json,defender,last_player_standing,Defender won by eliminating all attackers,7,38,16,10,3.5,3.3,0,2,0,932,
|
||||
|
-40
@@ -1,40 +0,0 @@
|
||||
# STANDARD Scoring All-40 Baseline
|
||||
|
||||
This baseline is the current comparison baseline for STANDARD scoring experiments. It was
|
||||
regenerated after fixing iterative-deepening timeout score propagation.
|
||||
Generated battle configs preserve archery/start-fire capability, per-unit flee capability,
|
||||
explicit zero-size battalions, no-profession heroes, forced commands, seed `1`, and the
|
||||
31-round defender timeout.
|
||||
|
||||
Raw rows are recorded in `standard_scoring_all40_post_bugfix_baseline.csv`.
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
| --- | ---: |
|
||||
| Real battles | 40 |
|
||||
| Valid simulated battles | 40 |
|
||||
| Invalid / no recoverable troops | 0 |
|
||||
| Attacker wins | 18 |
|
||||
| Defender wins | 22 |
|
||||
| Last-player-standing endings | 39 |
|
||||
| Max-round defender endings | 1 |
|
||||
|
||||
## Closest Battles By Survivor Margin
|
||||
|
||||
Survivor margin is `attacker_surviving_troops - defender_surviving_troops`.
|
||||
|
||||
| Action seq | Winner | End reason | Attacker troops | Defender troops | Margin |
|
||||
| ---: | --- | --- | ---: | ---: | ---: |
|
||||
| 2584 | defender | last_player_standing | 0 | 168 | -168 |
|
||||
| 2654 | defender | last_player_standing | 0 | 467 | -467 |
|
||||
| 6705 | defender | last_player_standing | 0 | 535 | -535 |
|
||||
| 307 | defender | last_player_standing | 0 | 648 | -648 |
|
||||
| 6248 | attacker | last_player_standing | 676 | 0 | +676 |
|
||||
| 13752 | defender | last_player_standing | 0 | 932 | -932 |
|
||||
| 1472 | defender | last_player_standing | 0 | 944 | -944 |
|
||||
| 11754 | defender | last_player_standing | 0 | 1000 | -1000 |
|
||||
| 7212 | defender | last_player_standing | 0 | 1273 | -1273 |
|
||||
| 399 | defender | last_player_standing | 0 | 1315 | -1315 |
|
||||
| 11012 | defender | last_player_standing | 0 | 1347 | -1347 |
|
||||
| 3701 | defender | last_player_standing | 0 | 1348 | -1348 |
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
action_seq,attacker_factions,attacker_player,defender_factions,defender_player,ai_vs_ai,real_winner,sim_winner,winner_match,real_round,sim_round,real_attacker_troops,real_defender_troops,sim_attacker_troops,sim_defender_troops
|
||||
306,1,AI,2,AI,true,attacker,attacker,true,0,19,1454,0,1388,0
|
||||
307,2,AI,1,AI,true,defender,defender,true,0,16,0,699,0,648
|
||||
399,2,AI,1,AI,true,defender,defender,true,0,6,0,1310,0,1315
|
||||
631,1,AI,2,AI,true,defender,defender,true,0,5,0,3766,0,3654
|
||||
1326,9,AI,6,AI,true,attacker,attacker,true,0,11,1715,0,1701,0
|
||||
1472,8,AI,3,human,false,defender,defender,true,0,31,0,1593,0,944
|
||||
2437,2,AI,1,AI,true,attacker,attacker,true,0,16,3146,0,3247,0
|
||||
2584,1,AI,2,AI,true,defender,defender,true,0,31,0,2028,0,168
|
||||
2654,2,AI,1,AI,true,defender,defender,true,0,28,0,335,0,467
|
||||
3536,1,AI,2,AI,true,attacker,attacker,true,0,12,2172,0,2528,0
|
||||
3701,8,AI,2,AI,true,defender,defender,true,0,30,0,2819,0,1348
|
||||
4298,2,AI,1,AI,true,defender,defender,true,0,3,0,3514,0,1977
|
||||
4576,2,AI,1,AI,true,defender,defender,true,0,4,0,2028,0,1986
|
||||
6014,2,AI,8,AI,true,defender,defender,true,0,6,0,1942,0,2252
|
||||
6248,7,AI,9,AI,true,attacker,attacker,true,0,25,520,0,676,0
|
||||
6408,4,human,7,AI,false,attacker,attacker,true,0,11,4156,0,4020,0
|
||||
6705,1,AI,2,AI,true,defender,defender,true,0,31,0,3316,0,535
|
||||
7212,2,AI,8,AI,true,defender,defender,true,0,8,0,1253,0,1273
|
||||
8217,8,AI,2,AI,true,attacker,attacker,true,0,22,920,0,2167,0
|
||||
8394,2,AI,8,AI,true,attacker,attacker,true,0,9,2449,0,3186,0
|
||||
8628,3,human,8,AI,false,attacker,attacker,true,0,5,2874,0,2760,0
|
||||
9975,2,AI,1,AI,true,defender,defender,true,0,4,0,7420,0,7840
|
||||
10229,5,AI,7,AI,true,attacker,attacker,true,0,23,2135,0,1816,0
|
||||
10631,1,AI,2,AI,true,attacker,attacker,true,0,1,4000,0,4000,0
|
||||
11011,2,AI,1,AI,true,defender,defender,true,0,5,0,3532,0,3658
|
||||
11012,9,AI,6,AI,true,defender,defender,true,0,30,0,846,0,1347
|
||||
11112,5,AI,4,human,false,defender,defender,true,0,3,0,7595,0,7595
|
||||
11297,1,AI,2,AI,true,attacker,attacker,true,0,1,2400,0,2400,0
|
||||
11469,5,AI,9,AI,true,defender,defender,true,0,8,0,787,0,1489
|
||||
11470,7,AI,5,AI,true,attacker,attacker,true,0,17,3220,0,3425,0
|
||||
11565,5,AI,7,AI,true,defender,defender,true,0,31,190,3420,78,3600
|
||||
11662,4,human,9,AI,false,attacker,attacker,true,0,8,7326,0,6624,0
|
||||
11754,7,AI,4,human,false,attacker,defender,false,0,30,2119,0,0,1000
|
||||
11941,3,human,5,AI,false,attacker,attacker,true,0,6,4901,0,4777,0
|
||||
11942,4,human,7,AI,false,attacker,attacker,true,0,5,8840,0,8572,0
|
||||
12136,9,AI,7,AI,true,attacker,attacker,true,0,21,1829,0,1581,0
|
||||
12137,2,AI,1,AI,true,defender,defender,true,0,8,0,1894,0,1892
|
||||
12138,3,human,5,AI,false,attacker,attacker,true,0,14,2960,0,2512,0
|
||||
12765,6,AI,9,AI,true,defender,defender,true,0,11,0,1773,0,1763
|
||||
13752,2,AI,1,AI,true,defender,defender,true,0,7,0,943,0,932
|
||||
|
-56
@@ -1,56 +0,0 @@
|
||||
# All-40 Real vs STANDARD Simulation
|
||||
|
||||
This table compares the saved-game Shardok winner side with a fresh
|
||||
STANDARD-vs-STANDARD simulator run from the extracted battle setup.
|
||||
Faction `3` and faction `4` are marked as human players; all other
|
||||
factions are marked as AI players.
|
||||
|
||||
- Battles checked: 40
|
||||
- Known real outcomes: 40
|
||||
- Winner-side matches on known outcomes: 39/40
|
||||
- Real saved-game winners: 19 attacker, 21 defender, 0 unknown
|
||||
- Sim baseline winners: 18 attacker, 22 defender
|
||||
- Battles involving a human side: 9
|
||||
|
||||
| Action seq | Attacker | Defender | Real winner | Sim winner | Match | Real troops | Sim troops |
|
||||
| ---: | --- | --- | --- | --- | --- | ---: | ---: |
|
||||
| 306 | 1 (AI) | 2 (AI) | attacker | attacker | yes | 1454 vs 0 | 1388 vs 0 |
|
||||
| 307 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 699 | 0 vs 648 |
|
||||
| 399 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 1310 | 0 vs 1315 |
|
||||
| 631 | 1 (AI) | 2 (AI) | defender | defender | yes | 0 vs 3766 | 0 vs 3654 |
|
||||
| 1326 | 9 (AI) | 6 (AI) | attacker | attacker | yes | 1715 vs 0 | 1701 vs 0 |
|
||||
| 1472 | 8 (AI) | 3 (human) | defender | defender | yes | 0 vs 1593 | 0 vs 944 |
|
||||
| 2437 | 2 (AI) | 1 (AI) | attacker | attacker | yes | 3146 vs 0 | 3247 vs 0 |
|
||||
| 2584 | 1 (AI) | 2 (AI) | defender | defender | yes | 0 vs 2028 | 0 vs 168 |
|
||||
| 2654 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 335 | 0 vs 467 |
|
||||
| 3536 | 1 (AI) | 2 (AI) | attacker | attacker | yes | 2172 vs 0 | 2528 vs 0 |
|
||||
| 3701 | 8 (AI) | 2 (AI) | defender | defender | yes | 0 vs 2819 | 0 vs 1348 |
|
||||
| 4298 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 3514 | 0 vs 1977 |
|
||||
| 4576 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 2028 | 0 vs 1986 |
|
||||
| 6014 | 2 (AI) | 8 (AI) | defender | defender | yes | 0 vs 1942 | 0 vs 2252 |
|
||||
| 6248 | 7 (AI) | 9 (AI) | attacker | attacker | yes | 520 vs 0 | 676 vs 0 |
|
||||
| 6408 | 4 (human) | 7 (AI) | attacker | attacker | yes | 4156 vs 0 | 4020 vs 0 |
|
||||
| 6705 | 1 (AI) | 2 (AI) | defender | defender | yes | 0 vs 3316 | 0 vs 535 |
|
||||
| 7212 | 2 (AI) | 8 (AI) | defender | defender | yes | 0 vs 1253 | 0 vs 1273 |
|
||||
| 8217 | 8 (AI) | 2 (AI) | attacker | attacker | yes | 920 vs 0 | 2167 vs 0 |
|
||||
| 8394 | 2 (AI) | 8 (AI) | attacker | attacker | yes | 2449 vs 0 | 3186 vs 0 |
|
||||
| 8628 | 3 (human) | 8 (AI) | attacker | attacker | yes | 2874 vs 0 | 2760 vs 0 |
|
||||
| 9975 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 7420 | 0 vs 7840 |
|
||||
| 10229 | 5 (AI) | 7 (AI) | attacker | attacker | yes | 2135 vs 0 | 1816 vs 0 |
|
||||
| 10631 | 1 (AI) | 2 (AI) | attacker | attacker | yes | 4000 vs 0 | 4000 vs 0 |
|
||||
| 11011 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 3532 | 0 vs 3658 |
|
||||
| 11012 | 9 (AI) | 6 (AI) | defender | defender | yes | 0 vs 846 | 0 vs 1347 |
|
||||
| 11112 | 5 (AI) | 4 (human) | defender | defender | yes | 0 vs 7595 | 0 vs 7595 |
|
||||
| 11297 | 1 (AI) | 2 (AI) | attacker | attacker | yes | 2400 vs 0 | 2400 vs 0 |
|
||||
| 11469 | 5 (AI) | 9 (AI) | defender | defender | yes | 0 vs 787 | 0 vs 1489 |
|
||||
| 11470 | 7 (AI) | 5 (AI) | attacker | attacker | yes | 3220 vs 0 | 3425 vs 0 |
|
||||
| 11565 | 5 (AI) | 7 (AI) | defender | defender | yes | 190 vs 3420 | 78 vs 3600 |
|
||||
| 11662 | 4 (human) | 9 (AI) | attacker | attacker | yes | 7326 vs 0 | 6624 vs 0 |
|
||||
| 11754 | 7 (AI) | 4 (human) | attacker | defender | no | 2119 vs 0 | 0 vs 1000 |
|
||||
| 11941 | 3 (human) | 5 (AI) | attacker | attacker | yes | 4901 vs 0 | 4777 vs 0 |
|
||||
| 11942 | 4 (human) | 7 (AI) | attacker | attacker | yes | 8840 vs 0 | 8572 vs 0 |
|
||||
| 12136 | 9 (AI) | 7 (AI) | attacker | attacker | yes | 1829 vs 0 | 1581 vs 0 |
|
||||
| 12137 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 1894 | 0 vs 1892 |
|
||||
| 12138 | 3 (human) | 5 (AI) | attacker | attacker | yes | 2960 vs 0 | 2512 vs 0 |
|
||||
| 12765 | 6 (AI) | 9 (AI) | defender | defender | yes | 0 vs 1773 | 0 vs 1763 |
|
||||
| 13752 | 2 (AI) | 1 (AI) | defender | defender | yes | 0 vs 943 | 0 vs 932 |
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
# ID Scoring Experiments, Seeds 1-4
|
||||
|
||||
These results use `id_standard_seed1.json` as the base generated battle shape:
|
||||
|
||||
- Map: `Alah`
|
||||
- Max rounds: 10
|
||||
- Setup: AI placement, forced single-option commands enabled
|
||||
- Attacker: iterative deepening, six longbowmen with professions 1-6
|
||||
- Defender: iterative deepening, six light infantry units with no attached heroes
|
||||
|
||||
The saved game zip at the repository root contains real Shardok battles, but the available
|
||||
`shardok_state` rows are latest-state snapshots and the sampled states are terminal. They are
|
||||
useful for saved-state ingestion checks, not for live AI decision win-rate measurement.
|
||||
|
||||
## Baseline
|
||||
|
||||
`STANDARD` attacker vs `STANDARD` defender:
|
||||
|
||||
- Attacker wins: 0 / 4
|
||||
- Defender wins: 4 / 4
|
||||
- Attacker win rate: 0%
|
||||
- Average defender surviving units: 6.00
|
||||
- Average defender surviving troops: 3510.25
|
||||
|
||||
Raw rows are recorded in `id_standard_baseline_seed1_4.csv`.
|
||||
|
||||
## Candidate Results
|
||||
|
||||
| Attacker scoring | Defender scoring | Attacker wins | Defender wins | Avg defender units | Avg defender troops | Notes |
|
||||
| --- | --- | ---: | ---: | ---: | ---: | --- |
|
||||
| `NORMALIZED` | `STANDARD` | 0 | 4 | 5.50 | 3224.00 | Better casualties than baseline, no win-rate movement. |
|
||||
| `MCTS_OPTIMIZED` | `STANDARD` | 0 | 4 | 5.50 | 3503.50 | Similar troop outcome to baseline. |
|
||||
| `STANDARD` with attacker unit decay removed | `STANDARD` | 0 | 4 | 6.00 | 3204.25 | Best casualty signal in this small sample, no win-rate movement. |
|
||||
|
||||
The attacker unit-decay experiment changes `StandardAIScoreCalculator::CombineAttackerScores` to
|
||||
use a constant unit multiplier, matching the defender hold-castle scoring behavior. The rationale is
|
||||
the same as the existing defender-side comment: decaying unit value late in the battle can make
|
||||
tactical actions look worse than waiting when a side is behind on unit value.
|
||||
|
||||
## Candidate Raw Rows
|
||||
|
||||
### `NORMALIZED` Attacker vs `STANDARD` Defender
|
||||
|
||||
```csv
|
||||
seed,attacker_scoring,defender_scoring,winner,end_reason,total_rounds,total_commands,attacker_ai_commands,defender_ai_commands,attacker_avg_depth,defender_avg_depth,attacker_surviving_units,defender_surviving_units,attacker_surviving_troops,defender_surviving_troops
|
||||
1,NORMALIZED,STANDARD,defender,last_player_standing,9,121,60,43,2.53333,3.44186,0,5,0,2665
|
||||
2,NORMALIZED,STANDARD,defender,last_player_standing,10,129,64,47,2.50000,3.46809,0,6,0,3102
|
||||
3,NORMALIZED,STANDARD,defender,last_player_standing,9,116,59,41,2.59322,3.31707,0,6,0,3752
|
||||
4,NORMALIZED,STANDARD,defender,last_player_standing,9,114,63,34,2.47619,3.47059,0,5,0,3377
|
||||
```
|
||||
|
||||
### `MCTS_OPTIMIZED` Attacker vs `STANDARD` Defender
|
||||
|
||||
```csv
|
||||
seed,attacker_scoring,defender_scoring,winner,end_reason,total_rounds,total_commands,attacker_ai_commands,defender_ai_commands,attacker_avg_depth,defender_avg_depth,attacker_surviving_units,defender_surviving_units,attacker_surviving_troops,defender_surviving_troops
|
||||
1,MCTS_OPTIMIZED,STANDARD,defender,last_player_standing,9,129,64,46,2.48438,3.54348,0,6,0,3203
|
||||
2,MCTS_OPTIMIZED,STANDARD,defender,last_player_standing,9,119,62,40,2.51613,3.22500,0,6,0,3559
|
||||
3,MCTS_OPTIMIZED,STANDARD,defender,last_player_standing,9,115,62,35,2.67742,3.37143,0,5,0,3969
|
||||
4,MCTS_OPTIMIZED,STANDARD,defender,last_player_standing,9,114,63,34,2.41270,3.41176,0,5,0,3283
|
||||
```
|
||||
|
||||
### `STANDARD` Attacker With Unit Decay Removed vs `STANDARD` Defender
|
||||
|
||||
```csv
|
||||
seed,attacker_scoring,defender_scoring,winner,end_reason,total_rounds,total_commands,attacker_ai_commands,defender_ai_commands,attacker_avg_depth,defender_avg_depth,attacker_surviving_units,defender_surviving_units,attacker_surviving_troops,defender_surviving_troops
|
||||
1,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,defender,last_player_standing,10,135,65,51,2.53846,3.45098,0,6,0,2707
|
||||
2,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,defender,last_player_standing,9,130,64,48,2.48438,3.58333,0,6,0,3149
|
||||
3,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,defender,last_player_standing,9,129,63,45,2.57143,3.44444,0,6,0,3401
|
||||
4,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,defender,last_player_standing,9,129,62,46,2.66129,3.41304,0,6,0,3560
|
||||
```
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
seed,attacker_scoring,defender_scoring,winner,end_reason,total_rounds,total_commands,attacker_ai_commands,defender_ai_commands,attacker_avg_depth,defender_avg_depth,attacker_surviving_units,defender_surviving_units,attacker_surviving_troops,defender_surviving_troops
|
||||
1,STANDARD,STANDARD,defender,last_player_standing,9,130,65,47,2.52308,3.61702,0,6,0,3376
|
||||
2,STANDARD,STANDARD,defender,last_player_standing,9,130,63,47,2.58730,3.29787,0,6,0,3704
|
||||
3,STANDARD,STANDARD,defender,last_player_standing,9,129,63,45,2.58730,3.44444,0,6,0,3401
|
||||
4,STANDARD,STANDARD,defender,last_player_standing,9,129,62,46,2.70968,3.39130,0,6,0,3560
|
||||
|
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"map_name": "Alah",
|
||||
"month": 4,
|
||||
"max_rounds": 10,
|
||||
"random_seed": 1,
|
||||
"use_configured_placements": false,
|
||||
"apply_forced_commands": true,
|
||||
"attacker": {
|
||||
"ai_algorithm": "ITERATIVE_DEEPENING",
|
||||
"scoring_calculator": "STANDARD",
|
||||
"units": [
|
||||
{ "profession": 1, "battalion_type_id": 4, "starting_position_index": 0, "battalion": { "size": 1000 } },
|
||||
{ "profession": 2, "battalion_type_id": 4, "starting_position_index": 0, "battalion": { "size": 1000 } },
|
||||
{ "profession": 3, "battalion_type_id": 4, "starting_position_index": 0, "battalion": { "size": 1000 } },
|
||||
{ "profession": 4, "battalion_type_id": 4, "starting_position_index": 0, "battalion": { "size": 1000 } },
|
||||
{ "profession": 5, "battalion_type_id": 4, "starting_position_index": 0, "battalion": { "size": 1000 } },
|
||||
{ "profession": 6, "battalion_type_id": 4, "starting_position_index": 0, "battalion": { "size": 1000 } }
|
||||
]
|
||||
},
|
||||
"defender": {
|
||||
"ai_algorithm": "ITERATIVE_DEEPENING",
|
||||
"scoring_calculator": "STANDARD",
|
||||
"units": [
|
||||
{ "profession": 14, "battalion_type_id": 0, "starting_position_index": -1, "battalion": { "size": 1000 } },
|
||||
{ "profession": 14, "battalion_type_id": 0, "starting_position_index": -1, "battalion": { "size": 1000 } },
|
||||
{ "profession": 14, "battalion_type_id": 0, "starting_position_index": -1, "battalion": { "size": 1000 } },
|
||||
{ "profession": 14, "battalion_type_id": 0, "starting_position_index": -1, "battalion": { "size": 1000 } },
|
||||
{ "profession": 14, "battalion_type_id": 0, "starting_position_index": -1, "battalion": { "size": 1000 } },
|
||||
{ "profession": 14, "battalion_type_id": 0, "starting_position_index": -1, "battalion": { "size": 1000 } }
|
||||
]
|
||||
}
|
||||
}
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
variant,attacker_scoring,defender_scoring,shardok_game_id,action_seq,attacker_units,defender_units,attacker_initial_troops,defender_initial_troops,attacker_troop_ratio,winner,end_reason,result,total_rounds,total_commands,attacker_ai_commands,defender_ai_commands,attacker_avg_depth,defender_avg_depth,attacker_surviving_units,defender_surviving_units,attacker_surviving_troops,defender_surviving_troops,elapsed_seconds
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_1_b3e7d8ab,306,2,3,1600.0,2600.0,0.6153846153846154,defender,last_player_standing,Defender won by eliminating all attackers,31,192,67,85,3.61194,3.61176,0,1,0,685,18.768
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_2_bf499d3c,307,2,1,1600.0,1000.0,1.6,defender,last_player_standing,Defender won by eliminating all attackers,20,82,32,20,3.09375,2.3,0,1,0,324,5.722
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_3_cd9141a,399,1,3,450.0,1454.0,0.30949105914718017,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,188,45,106,2,3.89623,1,3,450,2454,30.201
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_4_6e7b9b,631,2,5,1310.0,4200.0,0.3119047619047619,defender,last_player_standing,Defender won by eliminating all attackers,8,70,20,33,3.55,3.90909,0,4,0,2991,9.564
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_5_8a57255a,1326,2,4,1800.0,0.0,,defender,last_player_standing,Defender won by eliminating all attackers,9,61,18,27,2.77778,3.18519,0,5,0,4037,10.772
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_6_ab8632ba,1472,2,2,1800.0,1600.0,1.125,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,198,69,84,3.13043,3.5,1,2,57,378,23.999
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_7_5cf74e0,2437,5,6,3766.0,3787.0,0.9944547134935305,defender,last_player_standing,Defender won by eliminating all attackers,18,267,82,158,3.30488,3.42405,0,5,0,2948,73.020
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_8_41ce0ebd,2584,2,7,1600.0,3668.0,0.4362050163576881,defender,last_player_standing,Defender won by eliminating all attackers,6,78,10,52,3.2,3.53846,0,7,0,3832,21.143
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_9_eb43d421,2654,3,2,2599.0,726.0,3.5798898071625342,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,317,186,93,2,4.12903,3,2,2599,726,33.775
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_a_950de1c1,3536,5,10,3858.0,2028.0,1.9023668639053255,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,428,138,247,2.52174,3.39271,1,6,375,4511,115.585
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_b_5d1d76e0,3701,2,6,1800.0,4240.0,0.42452830188679247,defender,last_player_standing,Defender won by eliminating all attackers,31,255,40,179,2.825,3.45251,0,4,0,2358,56.109
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_c_7bdb54dd,4298,4,6,3123.0,2172.0,1.4378453038674033,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,599,247,309,2,3.69579,4,6,2914,4085,128.531
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_d_689ee2fa,4576,4,3,3300.0,2135.0,1.5456674473067915,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,415,210,148,2,3.33108,4,3,629,1771,41.509
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_e_89c90dc6,6014,2,3,1512.0,2600.0,0.5815384615384616,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,214,97,79,2,3.29114,2,3,1512,2600,27.933
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_f_3a9d9136,6248,2,2,1800.0,1600.0,1.125,attacker,last_player_standing,Attacker won by eliminating all defenders,12,86,32,27,3.25,3.37037,2,0,1455,0,7.670
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_10_c972d3e1,6408,5,2,4169.0,1223.0,3.4088307440719543,attacker,last_player_standing,Attacker won by eliminating all defenders,15,145,96,24,3.38542,2.54167,5,0,3832,0,30.677
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_11_e0611233,6705,5,5,3510.0,4400.0,0.7977272727272727,attacker,last_player_standing,Attacker won by eliminating all defenders,28,219,101,81,2.9802,3.48148,3,0,2150,0,40.168
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_12_84a36154,7212,2,2,1156.0,1800.0,0.6422222222222222,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,254,62,124,2,3.45161,2,2,1156,1800,15.591
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_13_2e98b1cf,8217,3,2,2270.0,1136.0,1.9982394366197183,attacker,last_player_standing,Attacker won by eliminating all defenders,24,185,90,53,3.46667,3.37736,3,0,1944,0,21.446
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_14_b5570863,8394,4,2,3600.0,1212.0,2.9702970297029703,attacker,last_player_standing,Attacker won by eliminating all defenders,20,130,74,26,3.41892,2.84615,4,0,2552,0,25.721
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_15_28a14d7d,8628,4,1,3011.0,800.0,3.76375,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,255,186,31,2,3,4,1,3011,800,5.159
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_16_3d6ec341,9975,5,11,4049.0,9110.0,0.44445664105378707,defender,last_player_standing,Defender won by eliminating all attackers,5,115,26,65,3.03846,2.98462,0,11,0,7033,33.494
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_17_3ba689e3,10229,2,1,1800.0,1000.0,1.8,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,160,81,13,3.85185,2.53846,1,1,56,727,15.586
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_18_9e463db3,10631,5,2,4000.0,29.0,137.93103448275863,attacker,last_player_standing,Attacker won by eliminating all defenders,10,104,55,27,2.98182,2.62963,5,0,3955,0,17.981
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_19_239725d3,11011,3,5,2092.0,4000.0,0.523,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,274,80,151,2.3625,3.75497,1,4,120,2749,65.354
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_1a_16e1ebf,11012,2,2,1600.0,1800.0,0.8888888888888888,defender,last_player_standing,Defender won by eliminating all attackers,21,112,30,48,2.9,3.3125,0,2,0,1328,12.932
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_1b_814a8b05,11112,2,11,1800.0,7595.0,0.2369980250164582,defender,last_player_standing,Defender won by eliminating all attackers,5,89,10,60,3.4,3.05,0,11,0,9248,26.113
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_1c_529ba6e5,11297,3,2,2400.0,0.0,,attacker,last_player_standing,Attacker won by eliminating all defenders,14,110,46,39,2.95652,3.4359,3,0,1569,0,20.096
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_1d_49993012,11469,3,2,2383.0,1800.0,1.323888888888889,attacker,last_player_standing,Attacker won by eliminating all defenders,27,159,73,34,3.52055,3.05882,3,0,2160,0,19.694
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_1e_fd8c60a8,11470,4,2,3600.0,1800.0,2.0,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,356,177,112,2.31073,3.47321,3,2,2267,952,18.046
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_1f_53309484,11565,1,4,334.0,3600.0,0.09277777777777778,defender,last_player_standing,Defender won by eliminating all attackers,1,8,1,0,3,0,0,4,0,3600,0.555
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_20_6d9f21cd,11662,9,3,7595.0,2600.0,2.921153846153846,attacker,last_player_standing,Attacker won by eliminating all defenders,11,174,111,41,3.1982,3.36585,9,0,7070,0,47.504
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_21_c536f279,11754,3,3,2470.0,2400.0,1.0291666666666666,defender,last_player_standing,Defender won by eliminating all attackers,7,55,20,22,3.65,3.54545,0,3,0,1624,10.181
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_22_a0f39b09,11941,6,2,4996.0,1800.0,2.7755555555555556,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,290,186,69,3.46237,3.52174,1,2,165,299,60.206
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_23_865a7a0e,11942,12,3,8916.0,2119.0,4.207645115620576,attacker,last_player_standing,Attacker won by eliminating all defenders,12,232,164,47,2.94512,3.59574,12,0,7940,0,77.700
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_24_f0d61a5a,12136,3,2,2089.0,1800.0,1.1605555555555556,attacker,last_player_standing,Attacker won by eliminating all defenders,15,98,50,25,3.18,3.12,2,0,1384,0,14.063
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_25_a19c4d87,12137,2,3,1524.0,2400.0,0.635,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,148,67,43,2.34328,2.55814,1,3,214,2282,19.123
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_26_d897bec7,12138,4,2,3301.0,1400.0,2.3578571428571427,defender,max_rounds_reached,Defender won by surviving maximum rounds,31,422,290,93,2,3.33333,4,2,3301,1400,6.678
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_27_c008989b,12765,2,2,1800.0,1800.0,1.0,defender,last_player_standing,Defender won by eliminating all attackers,30,108,40,30,3.5,2.86667,0,2,0,1678,9.069
|
||||
no_attacker_unit_decay,STANDARD_NO_ATTACKER_UNIT_DECAY,STANDARD,389ffb1901903118_28_b33e2868,13752,2,2,1798.0,1600.0,1.12375,defender,last_player_standing,Defender won by eliminating all attackers,30,189,39,123,3.15385,3.98374,0,2,0,1114,26.389
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user