mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 12:55:41 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8d7e03afe |
@@ -1,8 +1,5 @@
|
||||
bazel-1.0.0.bazelrc
|
||||
|
||||
# for now: filter out annoying TASTY warnings
|
||||
common --ui_event_filters=-INFO
|
||||
|
||||
common --enable_bzlmod
|
||||
|
||||
# Don't use toolchains_llvm for the swift app build
|
||||
@@ -19,9 +16,9 @@ common --worker_sandboxing
|
||||
common --local_test_jobs=64
|
||||
common --jobs=64
|
||||
|
||||
common --cxxopt="--std=c++23"
|
||||
common --cxxopt="--std=c++20"
|
||||
common --cxxopt="-Wno-deprecated-non-prototype"
|
||||
common --host_cxxopt="--std=c++23"
|
||||
common --host_cxxopt="--std=c++20"
|
||||
|
||||
common --javacopt="-Xlint:-options"
|
||||
|
||||
@@ -29,9 +26,6 @@ common --javacopt="-Xlint:-options"
|
||||
common --linkopt=-Wl
|
||||
common:macos --linkopt=-Wl,-no_warn_duplicate_libraries
|
||||
|
||||
# Fix Xcode version caching issue - avoids need for `bazel clean --expunge` after Xcode updates
|
||||
common:macos --repo_env=DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||
|
||||
common --java_language_version=17
|
||||
common --java_runtime_version=remotejdk_17
|
||||
common --tool_java_language_version=17
|
||||
|
||||
@@ -6,7 +6,3 @@
|
||||
*.bytes filter=lfs diff=lfs merge=lfs -text
|
||||
*.psd filter=lfs diff=lfs merge=lfs -text
|
||||
*.ttf filter=lfs diff=lfs merge=lfs -text
|
||||
# Exclude pre-existing font files that were committed as blobs (not LFS pointers)
|
||||
src/main/csharp/**/GUI[[:space:]]Pro[[:space:]]Kit*/**/*.ttf !filter !diff !merge
|
||||
src/main/csharp/**/Modern[[:space:]]UI[[:space:]]Pack/**/*.ttf !filter !diff !merge
|
||||
*.herodata filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
@@ -3,23 +3,13 @@ name: Bazel Test
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'WORKSPACE'
|
||||
- 'MODULE.bazel'
|
||||
- 'BUILD.bazel'
|
||||
- '.github/workflows/bazel_test.yml'
|
||||
- '!src/main/csharp/**'
|
||||
- '!src/test/csharp/**'
|
||||
paths-ignore:
|
||||
- "src/main/csharp/**"
|
||||
- "src/test/csharp/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'WORKSPACE'
|
||||
- 'MODULE.bazel'
|
||||
- 'BUILD.bazel'
|
||||
- '.github/workflows/bazel_test.yml'
|
||||
- '!src/main/csharp/**'
|
||||
- '!src/test/csharp/**'
|
||||
paths-ignore:
|
||||
- "src/main/csharp/**"
|
||||
- "src/test/csharp/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -34,54 +24,10 @@ jobs:
|
||||
with:
|
||||
lfs: false
|
||||
- name: Run tests
|
||||
id: test
|
||||
continue-on-error: true
|
||||
run: bazel test --build_event_json_file=test.json //src/test/... //src/main/go/...
|
||||
- name: Collect failed test logs
|
||||
if: always()
|
||||
run: |
|
||||
# Remove any existing failed_test_logs directory and create fresh
|
||||
rm -rf failed_test_logs
|
||||
mkdir -p failed_test_logs
|
||||
# Extract failed test targets from test.json and copy their logs
|
||||
# The test.json is in JSONL format - one JSON object per line
|
||||
# We look for lines with testResult that have a status other than PASSED
|
||||
if [ -f test.json ]; then
|
||||
grep '"testResult"' test.json | \
|
||||
grep '"status"' | \
|
||||
grep -v '"status":"PASSED"' | \
|
||||
grep -o '"label":"[^"]*"' | \
|
||||
cut -d'"' -f4 | \
|
||||
sort -u | \
|
||||
while read target; do
|
||||
# Convert target like //src/test/cpp/...:test_name to path
|
||||
log_path=$(echo "$target" | sed 's|^//||' | sed 's|:|/|')
|
||||
if [ -f "bazel-testlogs/$log_path/test.log" ]; then
|
||||
log_name=$(echo "$log_path" | tr '/' '_')
|
||||
if cp "bazel-testlogs/$log_path/test.log" "failed_test_logs/${log_name}.log"; then
|
||||
echo "Collected log for failed test: $target"
|
||||
else
|
||||
echo "Error: Failed to copy log for $target"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
# List what we collected
|
||||
echo "Collected logs:"
|
||||
ls -lh failed_test_logs/ 2>/dev/null || echo "No logs collected"
|
||||
- name: Archive test results
|
||||
if: always()
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test.json
|
||||
path: test.json
|
||||
- name: Archive failed test logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: failed-test-logs
|
||||
path: failed_test_logs/
|
||||
if-no-files-found: ignore
|
||||
- name: Fail if tests failed
|
||||
if: steps.test.outcome == 'failure'
|
||||
run: exit 1
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
name: Build Linux Sysroot
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Sysroot version (e.g., v2, v3)'
|
||||
required: true
|
||||
default: 'v2'
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-sysroot:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build sysroot
|
||||
run: ./tools/sysroot/build_sysroot.sh
|
||||
|
||||
- name: Upload sysroot artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ubuntu-noble-sysroot
|
||||
path: tools/sysroot/output/
|
||||
|
||||
- name: Install AWS CLI
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y awscli
|
||||
|
||||
- name: Upload to DigitalOcean Spaces
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: |
|
||||
# Upload sysroot tarball to DO Spaces
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.tar.xz \
|
||||
s3://eagle0/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
--acl public-read
|
||||
|
||||
# Upload sha256 file
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256 \
|
||||
s3://eagle0/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.sha256 \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
--acl public-read
|
||||
|
||||
echo ""
|
||||
echo "=== Sysroot uploaded ==="
|
||||
echo "URL: https://eagle0.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz"
|
||||
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)"
|
||||
echo ""
|
||||
echo "Update MODULE.bazel with:"
|
||||
echo "sysroot("
|
||||
echo " name = \"linux_sysroot\","
|
||||
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)\","
|
||||
echo " urls = [\"https://eagle0.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
|
||||
echo ")"
|
||||
@@ -4,21 +4,19 @@ on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- ".github/workflows/client_presigner.yml"
|
||||
- "src/main/go/net/eagle0/client_download/**"
|
||||
- "src/main/go/net/eagle0/util/**"
|
||||
- "src/main/go/**"
|
||||
- "!src/main/go/net/eagle0/web_functions/name-generator/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/client_presigner.yml"
|
||||
- "src/main/go/net/eagle0/client_download/**"
|
||||
- "src/main/go/net/eagle0/util/**"
|
||||
- "src/main/go/**"
|
||||
- "!src/main/go/net/eagle0/web_functions/name-generator/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
client-presigner:
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -26,7 +24,7 @@ jobs:
|
||||
lfs: false
|
||||
clean: false
|
||||
- name: Build Client Presigner
|
||||
run: bazel build --platforms=@io_bazel_rules_go//go/toolchain:linux_amd64 //src/main/go/net/eagle0/client_download
|
||||
run: bazel build //src/main/go/net/eagle0/client_download
|
||||
- name: Archive presigner binary
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
name: Docker Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 'src/main/cpp/**'
|
||||
- 'src/main/scala/**'
|
||||
- 'src/main/protobuf/**'
|
||||
- 'src/main/resources/**'
|
||||
- 'ci/BUILD.bazel'
|
||||
- 'MODULE.bazel'
|
||||
- '.github/workflows/docker_build.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push_images:
|
||||
description: 'Push images to container registry'
|
||||
required: true
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-eagle:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Build Eagle Docker image
|
||||
run: bazel build //ci:eagle_server_image
|
||||
|
||||
- name: Login to DigitalOcean Container Registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
run: |
|
||||
mkdir -p ~/.docker
|
||||
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
|
||||
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
|
||||
- name: Push Eagle image to DO registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
run: bazel run //ci:eagle_server_push
|
||||
|
||||
build-shardok:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Build Shardok Docker image (cross-compile for Linux)
|
||||
run: bazel build --platforms=//:linux_x86_64 //ci:shardok_server_image
|
||||
|
||||
- name: Login to DigitalOcean Container Registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
env:
|
||||
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
run: |
|
||||
mkdir -p ~/.docker
|
||||
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
|
||||
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
|
||||
- name: Push Shardok image to DO registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
run: bazel run --platforms=//:linux_x86_64 //ci:shardok_server_push
|
||||
@@ -1,82 +0,0 @@
|
||||
name: Installer Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- ".github/workflows/installer_build.yml"
|
||||
- "src/main/csharp/net/eagle0/clients/win/installer/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/installer_build.yml"
|
||||
- "src/main/csharp/net/eagle0/clients/win/installer/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-installer:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
clean: false
|
||||
|
||||
- name: Setup .NET 8
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/EagleInstaller.csproj
|
||||
|
||||
- name: Build installer
|
||||
run: dotnet publish src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/EagleInstaller.csproj -c Release -r win-x64 --self-contained true --output ./installer-output
|
||||
|
||||
- name: Archive installer binary
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: eagle-installer
|
||||
path: ./installer-output/EagleInstaller.exe
|
||||
|
||||
- name: Verify installer exists
|
||||
if: success()
|
||||
run: |
|
||||
if [ ! -f "./installer-output/EagleInstaller.exe" ]; then
|
||||
echo "ERROR: EagleInstaller.exe not found at expected location"
|
||||
echo "Directory contents:"
|
||||
ls -la ./installer-output/
|
||||
exit 1
|
||||
fi
|
||||
echo "Installer found at correct location"
|
||||
|
||||
- name: Deploy installer
|
||||
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: |
|
||||
INSTALLER_PATH="$(pwd)/installer-output/EagleInstaller.exe"
|
||||
echo "Using absolute path: $INSTALLER_PATH"
|
||||
bazel run //src/main/go/net/eagle0/build/installer_build_handler:installer_build_handler -- "$INSTALLER_PATH"
|
||||
|
||||
- name: Update unified manifest
|
||||
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: |
|
||||
# Create installer manifest content
|
||||
INSTALLER_SHA=$(sha256sum ./installer-output/EagleInstaller.exe | cut -d' ' -f1)
|
||||
echo "installer_version=$INSTALLER_SHA" > /tmp/installer_manifest.txt
|
||||
echo "installer_url=installer/EagleInstaller.exe" >> /tmp/installer_manifest.txt
|
||||
|
||||
echo "=== Installer manifest content ==="
|
||||
cat /tmp/installer_manifest.txt
|
||||
echo "=================================="
|
||||
|
||||
# Update the unified manifest
|
||||
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- installer /tmp/installer_manifest.txt
|
||||
@@ -3,15 +3,21 @@ name: Mac History Editor Build
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- ".github/workflows/mac_history_build.yml"
|
||||
- "src/main/swift/net/eagle0/EagleGameHistoryViewer/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/**"
|
||||
paths-ignore:
|
||||
- "src/main/cpp/**"
|
||||
- "src/main/scala/**"
|
||||
- "src/main/csharp/**"
|
||||
- "src/test/cpp/**"
|
||||
- "src/test/scala/**"
|
||||
- "src/test/csharp/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/mac_history_build.yml"
|
||||
- "src/main/swift/net/eagle0/EagleGameHistoryViewer/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/**"
|
||||
paths-ignore:
|
||||
- "src/main/cpp/**"
|
||||
- "src/main/scala/**"
|
||||
- "src/main/csharp/**"
|
||||
- "src/test/cpp/**"
|
||||
- "src/test/scala/**"
|
||||
- "src/test/csharp/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -3,25 +3,13 @@ name: Shardok Build
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 'src/main/cpp/**'
|
||||
- 'src/main/proto/net/eagle0/shardok/**'
|
||||
- 'src/main/proto/net/eagle0/common/**'
|
||||
- 'src/main/go/net/eagle0/build/**'
|
||||
- 'WORKSPACE'
|
||||
- 'MODULE.bazel'
|
||||
- 'BUILD.bazel'
|
||||
- '.github/workflows/shardok_build.yml'
|
||||
paths-ignore:
|
||||
- "src/main/csharp/**"
|
||||
- "src/test/csharp/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/main/cpp/**'
|
||||
- 'src/main/proto/net/eagle0/shardok/**'
|
||||
- 'src/main/proto/net/eagle0/common/**'
|
||||
- 'src/main/go/net/eagle0/build/**'
|
||||
- 'WORKSPACE'
|
||||
- 'MODULE.bazel'
|
||||
- 'BUILD.bazel'
|
||||
- '.github/workflows/shardok_build.yml'
|
||||
paths-ignore:
|
||||
- "src/main/csharp/**"
|
||||
- "src/test/csharp/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -3,33 +3,17 @@ name: Unity Build
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- ".github/workflows/unity_build.yml"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/**"
|
||||
- "src/main/proto/**"
|
||||
- "scripts/build_protos.sh"
|
||||
- "scripts/build_plugins.sh"
|
||||
- "scripts/build_windows_plugin.sh"
|
||||
- "ci/github_actions/build_unity.sh"
|
||||
- "ci/github_actions/restore_library.sh"
|
||||
- "ci/github_actions/persist_library.sh"
|
||||
- "MODULE.bazel"
|
||||
- "WORKSPACE"
|
||||
- "src/main/proto/net/eagle0/eagle/**/BUILD.bazel"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/unity_build.yml"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/**"
|
||||
- "src/main/proto/**"
|
||||
- "scripts/build_protos.sh"
|
||||
- "scripts/build_plugins.sh"
|
||||
- "scripts/build_windows_plugin.sh"
|
||||
- "ci/github_actions/build_unity.sh"
|
||||
- "ci/github_actions/restore_library.sh"
|
||||
- "ci/github_actions/persist_library.sh"
|
||||
- "MODULE.bazel"
|
||||
- "WORKSPACE"
|
||||
- "src/main/proto/**/BUILD.bazel"
|
||||
paths-ignore:
|
||||
- "src/main/cpp/**"
|
||||
- "src/main/scala/**"
|
||||
- "src/test/cpp/**"
|
||||
- "src/test/scala/**"
|
||||
# pull_request:
|
||||
# paths-ignore:
|
||||
# - "src/main/cpp/**"
|
||||
# - "src/main/scala/**"
|
||||
# - "src/test/cpp/**"
|
||||
# - "src/test/scala/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -52,18 +36,10 @@ jobs:
|
||||
- name: Persist Library/
|
||||
run: ./ci/github_actions/persist_library.sh
|
||||
- name: Deploy Windows unity
|
||||
if: success() #&& github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: bazel run //src/main/go/net/eagle0/build/unity3d_windows_build_handler:unity3d_windows_build_handler -- "/tmp/eagle0/eagle0WIN" "/tmp/unity_manifest.txt"
|
||||
|
||||
- name: Update unified manifest
|
||||
if: success() #&& github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- unity3d /tmp/unity_manifest.txt
|
||||
run: bazel run //src/main/go/net/eagle0/build/unity3d_windows_build_handler:unity3d_windows_build_handler -- "/tmp/eagle0/eagle0WIN"
|
||||
- name: Archive build log
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ project/boot/
|
||||
project/plugins/project/
|
||||
project/target/
|
||||
bazel-bin
|
||||
bazel-eagle0*
|
||||
bazel-eagle0
|
||||
bazel-out
|
||||
bazel-testlogs
|
||||
.ijwb
|
||||
@@ -32,9 +32,9 @@ buildWin.sh
|
||||
__pycache__/
|
||||
scripts/refresh_name_layers/vendor/
|
||||
scripts/refresh_name_layers/refresh_name_layers.zip
|
||||
.pre-commit-config.yaml
|
||||
.bazelbsp
|
||||
.bsp
|
||||
.metals
|
||||
api_keys.txt
|
||||
|
||||
src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/Packages/com.unity.dedicated-server/
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# See https://pre-commit.com for more information
|
||||
# See https://pre-commit.com/hooks.html for more hooks
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.3.0
|
||||
hooks:
|
||||
- id: check-added-large-files
|
||||
- id: no-commit-to-branch
|
||||
args: [--branch, main]
|
||||
- repo: https://github.com/pocc/pre-commit-hooks
|
||||
rev: v1.3.5
|
||||
hooks:
|
||||
- id: clang-format
|
||||
args: [-i, --no-diff]
|
||||
types_or: ["c++", "c#"]
|
||||
exclude: ^src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins
|
||||
- repo: https://github.com/yoheimuta/protolint
|
||||
rev: v0.42.2
|
||||
hooks:
|
||||
- id: protolint
|
||||
args: [-fix]
|
||||
exclude: ^src/main/protobuf/scalapb/
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: scalafmt
|
||||
name: scalafmt
|
||||
language: system
|
||||
entry: scalafmt -i -f
|
||||
types_or: ["scala"]
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: gazelle
|
||||
name: gazelle
|
||||
language: system
|
||||
entry: ./scripts/pre-commit-gazelle.sh
|
||||
files: '(\.go|\.proto|BUILD\.bazel|BUILD|WORKSPACE|WORKSPACE\.bazel|\.bzl)$'
|
||||
pass_filenames: false
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: update-action-result-types
|
||||
name: update-action-result-types
|
||||
language: system
|
||||
entry: ./scripts/updateActionResultTypes.sh
|
||||
files: 'src/main/protobuf/net/eagle0/eagle/common/action_result_type.proto'
|
||||
+2
-47
@@ -1,47 +1,2 @@
|
||||
version = "3.9.9"
|
||||
runner.dialect = scala3
|
||||
rewrite.scala3.convertToNewSyntax = true
|
||||
# Keep braces, don't use significant indentation
|
||||
# rewrite.scala3.removeOptionalBraces = yes
|
||||
rewrite.scala3.insertEndMarkerMinLines = 15
|
||||
rewrite.scala3.removeEndMarkerMaxLines = 14
|
||||
|
||||
# Strip margin settings
|
||||
assumeStandardLibraryStripMargin = false
|
||||
align.stripMargin = true
|
||||
|
||||
# Code Style & Formatting
|
||||
align.preset = more
|
||||
align.multiline = true
|
||||
align.arrowEnumeratorGenerator = true
|
||||
spaces.inImportCurlyBraces = false
|
||||
spaces.beforeContextBoundColon = Never
|
||||
maxColumn = 120
|
||||
docstrings.style = Asterisk
|
||||
docstrings.wrap = yes
|
||||
|
||||
# Method chaining
|
||||
newlines.beforeCurlyLambdaParams = multilineWithCaseOnly
|
||||
optIn.breakChainOnFirstMethodDot = true
|
||||
includeCurlyBraceInSelectChains = false
|
||||
|
||||
# Advanced Scala 3 Features
|
||||
rewrite.scala3.countEndMarkerLines = all
|
||||
rewrite.redundantBraces.stringInterpolation = true
|
||||
rewrite.redundantBraces.parensForOneLineApply = true
|
||||
|
||||
# Project-Specific Considerations
|
||||
optIn.annotationNewlines = true
|
||||
runner.optimizer.forceConfigStyleMinArgCount = 3
|
||||
|
||||
# Import sorting configuration
|
||||
rewrite.rules = [SortImports, RedundantBraces, RedundantParens]
|
||||
rewrite.imports.sort = scalastyle
|
||||
rewrite.imports.groups = [
|
||||
["java\\..*"],
|
||||
["javax\\..*"],
|
||||
["scala\\..*"],
|
||||
[".*"]
|
||||
]
|
||||
rewrite.imports.contiguousGroups = only
|
||||
rewrite.trailingCommas.style = never
|
||||
version = "3.6.1"
|
||||
runner.dialect = scala213
|
||||
|
||||
@@ -3,15 +3,6 @@ load("@io_bazel_rules_go//go:def.bzl", "nogo")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
# Platform for cross-compiling to Linux x86_64
|
||||
platform(
|
||||
name = "linux_x86_64",
|
||||
constraint_values = [
|
||||
"@platforms//os:linux",
|
||||
"@platforms//cpu:x86_64",
|
||||
],
|
||||
)
|
||||
|
||||
gazelle(name = "gazelle")
|
||||
|
||||
# gazelle:proto file
|
||||
|
||||
@@ -1,320 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.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
|
||||
|
||||
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.0.32f1) with comprehensive protobuf integration (100+ .proto files)
|
||||
- Key components: `EagleConnection.cs` (gRPC client), `EagleGameController.cs` (main game logic)
|
||||
- Real-time bidirectional streaming with server via `PersistentClientConnection.cs`
|
||||
- Strategic map UI in `Assets/Eagle/`, tactical battle UI in `Assets/Shardok/`
|
||||
- Seamless transition between strategic gameplay and hex-based tactical combat
|
||||
|
||||
**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
|
||||
|
||||
## 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
|
||||
+110
-206
@@ -1,269 +1,173 @@
|
||||
module(name = "net_eagle0")
|
||||
|
||||
# Version constants
|
||||
SCALA_VERSION = "3.7.2"
|
||||
|
||||
NETTY_VERSION = "4.1.110.Final"
|
||||
|
||||
SCALAPB_VERSION = "1.0.0-alpha.1"
|
||||
|
||||
AWS_SDK_VERSION = "2.28.1"
|
||||
bazel_dep(name = "apple_support", repo_name = "build_bazel_apple_support", version = "1.21.1")
|
||||
|
||||
#
|
||||
# Core Build Tools
|
||||
# bazel-toolchain
|
||||
#
|
||||
|
||||
bazel_dep(name = "bazel_skylib", version = "1.8.1")
|
||||
bazel_dep(name = "rules_pkg", version = "1.1.0")
|
||||
|
||||
#
|
||||
# Language Support - Scala
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_scala", version = "7.1.1")
|
||||
|
||||
scala_config = use_extension(
|
||||
"@rules_scala//scala/extensions:config.bzl",
|
||||
"scala_config",
|
||||
)
|
||||
scala_config.settings(scala_version = SCALA_VERSION)
|
||||
|
||||
scala_deps = use_extension(
|
||||
"@rules_scala//scala/extensions:deps.bzl",
|
||||
"scala_deps",
|
||||
)
|
||||
scala_deps.scala()
|
||||
scala_deps.scalatest()
|
||||
scala_deps.scala_proto()
|
||||
|
||||
#
|
||||
# Language Support - C++
|
||||
#
|
||||
|
||||
bazel_dep(name = "toolchains_llvm", version = "1.6.0")
|
||||
bazel_dep(name = "toolchains_llvm", version = "1.2.0")
|
||||
|
||||
# Configure and register the toolchain.
|
||||
llvm = use_extension("@toolchains_llvm//toolchain/extensions:llvm.bzl", "llvm")
|
||||
|
||||
# Native toolchain (macOS -> macOS, Linux -> Linux)
|
||||
llvm.toolchain(
|
||||
name = "llvm_toolchain",
|
||||
llvm_version = "20.1.2",
|
||||
llvm_version = "19.1.0",
|
||||
)
|
||||
|
||||
# Cross-compilation toolchain (macOS -> Linux x86_64)
|
||||
# Uses the same LLVM distribution but with a Linux sysroot
|
||||
llvm.toolchain(
|
||||
name = "llvm_toolchain_linux",
|
||||
llvm_version = "20.1.2",
|
||||
use_repo(llvm, "llvm_toolchain")
|
||||
|
||||
# Set dev_dependency so we can turn this off for swift MacOS builds
|
||||
register_toolchains(
|
||||
"@llvm_toolchain//:all",
|
||||
dev_dependency = True,
|
||||
)
|
||||
|
||||
# Linux sysroot for cross-compilation (Chromium's Debian sysroot)
|
||||
llvm.sysroot(
|
||||
name = "llvm_toolchain_linux",
|
||||
label = "@linux_sysroot//sysroot",
|
||||
targets = ["linux-x86_64"],
|
||||
)
|
||||
|
||||
use_repo(llvm, "llvm_toolchain", "llvm_toolchain_linux")
|
||||
|
||||
# Download the Linux sysroot (Ubuntu 24.04 Noble for C++23 support)
|
||||
# Built by: .github/workflows/build_sysroot.yml
|
||||
# To rebuild: Run the "Build Linux Sysroot" workflow with a new version, then update sha256 and URL
|
||||
sysroot = use_repo_rule("@toolchains_llvm//toolchain:sysroot.bzl", "sysroot")
|
||||
sysroot(
|
||||
name = "linux_sysroot",
|
||||
# TODO: Update sha256 after running sysroot build workflow with version v2
|
||||
sha256 = "PLACEHOLDER_RUN_SYSROOT_WORKFLOW_FIRST",
|
||||
urls = ["https://eagle0.sfo3.digitaloceanspaces.com/sysroot/v2/ubuntu_noble_amd64_sysroot.tar.xz"],
|
||||
)
|
||||
|
||||
#
|
||||
# Language Support - Go
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_go", version = "0.56.1", repo_name = "io_bazel_rules_go")
|
||||
bazel_dep(name = "gazelle", version = "0.45.0", repo_name = "bazel_gazelle")
|
||||
bazel_dep(name = "rules_pkg", version = "1.0.1")
|
||||
bazel_dep(name = "bazel_skylib", version = "1.7.1")
|
||||
bazel_dep(name = "protobuf", repo_name = "com_google_protobuf", version = "29.2")
|
||||
bazel_dep(name = "grpc", version = "1.71.0")
|
||||
bazel_dep(name = "grpc-java", version = "1.71.0")
|
||||
bazel_dep(name = "googletest", version = "1.15.2")
|
||||
bazel_dep(name = "rules_go", repo_name = "io_bazel_rules_go", version = "0.50.1")
|
||||
bazel_dep(name = "gazelle", repo_name = "bazel_gazelle", version = "0.40.0")
|
||||
|
||||
go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk")
|
||||
|
||||
go_sdk.download(version = "1.23.3")
|
||||
|
||||
go_deps = use_extension("@bazel_gazelle//:extensions.bzl", "go_deps")
|
||||
|
||||
go_deps.from_file(go_mod = "//:go.mod")
|
||||
|
||||
use_repo(
|
||||
go_deps,
|
||||
"com_github_aws_aws_sdk_go_v2",
|
||||
"com_github_aws_aws_sdk_go_v2_config",
|
||||
"com_github_aws_aws_sdk_go_v2_credentials",
|
||||
"com_github_aws_aws_sdk_go_v2_service_s3",
|
||||
"org_golang_google_grpc",
|
||||
"org_golang_google_protobuf",
|
||||
"org_golang_x_text",
|
||||
"com_github_google_go_cmp",
|
||||
)
|
||||
|
||||
#
|
||||
# Platform Support - Apple/iOS
|
||||
#
|
||||
|
||||
bazel_dep(name = "apple_support", version = "1.21.1", repo_name = "build_bazel_apple_support")
|
||||
bazel_dep(name = "rules_apple", version = "3.16.1", repo_name = "build_bazel_rules_apple")
|
||||
bazel_dep(name = "rules_swift", version = "2.3.1", repo_name = "build_bazel_rules_swift")
|
||||
#go_sdk.nogo(
|
||||
# nogo = "//:my_nogo",
|
||||
#)
|
||||
|
||||
#
|
||||
# Protocol Buffers & RPC
|
||||
# rules_jvm_external
|
||||
#
|
||||
|
||||
bazel_dep(name = "protobuf", version = "29.2", repo_name = "com_google_protobuf")
|
||||
bazel_dep(name = "grpc", version = "1.71.0")
|
||||
bazel_dep(name = "grpc-java", version = "1.71.0")
|
||||
bazel_dep(name = "flatbuffers", version = "25.2.10")
|
||||
scala_version = "2.13.14"
|
||||
|
||||
#
|
||||
# Testing
|
||||
#
|
||||
|
||||
bazel_dep(name = "googletest", version = "1.17.0")
|
||||
|
||||
#
|
||||
# Container Images (OCI)
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_oci", version = "2.2.6")
|
||||
bazel_dep(name = "aspect_bazel_lib", version = "2.16.0")
|
||||
|
||||
oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
|
||||
|
||||
# Base image for Eagle (Java 17)
|
||||
oci.pull(
|
||||
name = "eclipse_temurin_17",
|
||||
digest = "sha256:d286b5352d98777bbf727f54038b04f0145cd9b76ca83f38a67aa111d4303748",
|
||||
image = "docker.io/library/eclipse-temurin",
|
||||
platforms = ["linux/amd64"],
|
||||
bazel_dep(
|
||||
name = "rules_jvm_external",
|
||||
version = "6.3",
|
||||
)
|
||||
|
||||
# Base image for Shardok (Ubuntu 24.04 for C++ runtime)
|
||||
oci.pull(
|
||||
name = "ubuntu_24_04",
|
||||
image = "docker.io/library/ubuntu",
|
||||
platforms = ["linux/amd64"],
|
||||
tag = "24.04",
|
||||
)
|
||||
use_repo(oci, "eclipse_temurin_17", "eclipse_temurin_17_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64")
|
||||
|
||||
#
|
||||
# Java/Scala Dependencies
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_jvm_external", version = "6.3")
|
||||
|
||||
maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")
|
||||
|
||||
maven.install(
|
||||
artifacts = [
|
||||
# Netty
|
||||
"io.netty:netty-codec:%s" % NETTY_VERSION,
|
||||
"io.netty:netty-codec-http:%s" % NETTY_VERSION,
|
||||
"io.netty:netty-codec-socks:%s" % NETTY_VERSION,
|
||||
"io.netty:netty-codec-http2:%s" % NETTY_VERSION,
|
||||
"io.netty:netty-handler:%s" % NETTY_VERSION,
|
||||
"io.netty:netty-buffer:%s" % NETTY_VERSION,
|
||||
"io.netty:netty-transport:%s" % NETTY_VERSION,
|
||||
"io.netty:netty-resolver:%s" % NETTY_VERSION,
|
||||
"io.netty:netty-common:%s" % NETTY_VERSION,
|
||||
"io.netty:netty-handler-proxy:%s" % NETTY_VERSION,
|
||||
|
||||
# ScalaPB
|
||||
"com.thesamet.scalapb:lenses_3:%s" % SCALAPB_VERSION,
|
||||
"com.thesamet.scalapb:scalapb-json4s_3:%s" % SCALAPB_VERSION,
|
||||
"com.thesamet.scalapb:scalapb-runtime_3:%s" % SCALAPB_VERSION,
|
||||
"com.thesamet.scalapb:scalapb-runtime-grpc_3:%s" % SCALAPB_VERSION,
|
||||
"com.thesamet.scalapb:compilerplugin_3:%s" % SCALAPB_VERSION,
|
||||
"com.thesamet.scalapb:protoc-bridge_3:0.9.9",
|
||||
|
||||
# JSON
|
||||
"org.json4s:json4s-ast_3:4.1.0-M8",
|
||||
"org.json4s:json4s-core_3:4.1.0-M8",
|
||||
"org.json4s:json4s-native_3:4.1.0-M8",
|
||||
|
||||
# Testing
|
||||
"org.scalamock:scalamock_3:7.4.1",
|
||||
|
||||
# AWS SDK
|
||||
"software.amazon.awssdk:s3-transfer-manager:%s" % AWS_SDK_VERSION,
|
||||
"software.amazon.awssdk:s3:%s" % AWS_SDK_VERSION,
|
||||
"software.amazon.awssdk:regions:%s" % AWS_SDK_VERSION,
|
||||
"software.amazon.awssdk:aws-core:%s" % AWS_SDK_VERSION,
|
||||
"software.amazon.awssdk:sdk-core:%s" % AWS_SDK_VERSION,
|
||||
"software.amazon.awssdk:utils:%s" % AWS_SDK_VERSION,
|
||||
"software.amazon.awssdk:http-client-spi:%s" % AWS_SDK_VERSION,
|
||||
|
||||
# AWS Lambda
|
||||
"com.amazonaws:aws-lambda-java-core:1.2.3",
|
||||
"com.amazonaws:aws-lambda-java-events:3.13.0",
|
||||
|
||||
# Logging
|
||||
"org.scala-lang:scala-library:%s" % scala_version,
|
||||
"io.netty:netty-codec:4.1.110.Final",
|
||||
"io.netty:netty-codec-http:4.1.110.Final",
|
||||
"io.netty:netty-codec-socks:4.1.110.Final",
|
||||
"io.netty:netty-codec-http2:4.1.110.Final",
|
||||
"io.netty:netty-handler:4.1.110.Final",
|
||||
"io.netty:netty-buffer:4.1.110.Final",
|
||||
"io.netty:netty-transport:4.1.110.Final",
|
||||
"io.netty:netty-resolver:4.1.110.Final",
|
||||
"io.netty:netty-common:4.1.110.Final",
|
||||
"io.netty:netty-handler-proxy:4.1.110.Final",
|
||||
"com.thesamet.scalapb:lenses_2.13:1.0.0-alpha.1",
|
||||
"com.thesamet.scalapb:scalapb-json4s_2.13:1.0.0-alpha.1",
|
||||
"com.thesamet.scalapb:scalapb-runtime_2.13:1.0.0-alpha.1",
|
||||
"com.thesamet.scalapb:scalapb-runtime-grpc_2.13:1.0.0-alpha.1",
|
||||
"com.thesamet.scalapb:compilerplugin_2.13:1.0.0-alpha.1",
|
||||
"com.thesamet.scalapb:protoc-bridge_2.13:0.9.8",
|
||||
"org.json4s:json4s-ast_2.13:4.0.7",
|
||||
"org.json4s:json4s-core_2.13:4.0.7",
|
||||
"org.json4s:json4s-native_2.13:4.0.7",
|
||||
"org.scalamock:scalamock_2.13:6.0.0",
|
||||
"software.amazon.awssdk:s3-transfer-manager:2.28.1",
|
||||
"software.amazon.awssdk:s3:2.28.1",
|
||||
"software.amazon.awssdk:regions:2.28.1",
|
||||
"software.amazon.awssdk:aws-core:2.28.1",
|
||||
"software.amazon.awssdk:sdk-core:2.28.1",
|
||||
"org.slf4j:slf4j-api:2.0.16",
|
||||
"org.slf4j:slf4j-simple:2.0.16",
|
||||
|
||||
# Other
|
||||
#"software.amazon.awssdk:sns:2.28.1",
|
||||
"software.amazon.awssdk:utils:2.28.1",
|
||||
"software.amazon.awssdk:http-client-spi:2.28.1",
|
||||
"org.reactivestreams:reactive-streams:1.0.4",
|
||||
"com.amazonaws:aws-lambda-java-core:1.2.3",
|
||||
"com.amazonaws:aws-lambda-java-events:3.13.0",
|
||||
"javax.xml.bind:jaxb-api:2.3.1",
|
||||
|
||||
# OkHttp (for SSE with read timeout support)
|
||||
"com.squareup.okhttp3:okhttp:4.12.0",
|
||||
"com.squareup.okhttp3:okhttp-sse:4.12.0",
|
||||
],
|
||||
duplicate_version_warning = "error",
|
||||
fail_if_repin_required = True,
|
||||
lock_file = "//:maven_install.json",
|
||||
lock_file = "//:maven_install.json", #
|
||||
repositories = [
|
||||
"https://repo1.maven.org/maven2",
|
||||
],
|
||||
)
|
||||
|
||||
use_repo(maven, "maven", "unpinned_maven")
|
||||
|
||||
#
|
||||
# External Libraries
|
||||
# rules_apple
|
||||
#
|
||||
|
||||
http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
|
||||
|
||||
# GTL (for parallel_hashmap)
|
||||
GTL_VERSION = "1.2.0"
|
||||
|
||||
GTL_SHA = "1969c45dd76eac0dd87e9e2b65cffe358617f4fe1bcd203f72f427742537913a"
|
||||
|
||||
http_archive(
|
||||
name = "gtl",
|
||||
build_file = "@//external:BUILD.gtl",
|
||||
sha256 = GTL_SHA,
|
||||
strip_prefix = "gtl-%s" % GTL_VERSION,
|
||||
url = "https://github.com/greg7mdp/gtl/archive/refs/tags/v%s.zip" % GTL_VERSION,
|
||||
bazel_dep(
|
||||
name = "rules_apple",
|
||||
repo_name = "build_bazel_rules_apple",
|
||||
version = "3.16.1",
|
||||
)
|
||||
bazel_dep(
|
||||
name = "rules_swift",
|
||||
repo_name = "build_bazel_rules_swift",
|
||||
version = "2.3.1",
|
||||
)
|
||||
|
||||
# Unity GoDice Plugin
|
||||
UNITY_GODICE_COMMIT = "18d6823991592e4d45fcc0f22692db849dea9063"
|
||||
#
|
||||
# Unbazelified imports
|
||||
#
|
||||
http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
|
||||
|
||||
UNITY_GODICE_SHA = "04e6ae4155965aab3372592e04061eba1256bb6ea7ccffd0d83f27574e5b3349"
|
||||
#
|
||||
# flatbuffers
|
||||
#
|
||||
bazel_dep(name = "flatbuffers", version = "25.2.10")
|
||||
|
||||
#
|
||||
# parallel-hashmap
|
||||
#
|
||||
|
||||
parallel_hashmap_version = "1.4.1"
|
||||
|
||||
parallel_hashmap_sha = "aac333eac3627698ca922102fd2a5921df8976906dff6b8e247a49e8cf363911"
|
||||
|
||||
http_archive(
|
||||
name = "parallel_hashmap",
|
||||
build_file = "@//external:BUILD.parallel_hashmap",
|
||||
sha256 = parallel_hashmap_sha,
|
||||
strip_prefix = "parallel-hashmap-%s" % parallel_hashmap_version,
|
||||
url = "https://github.com/greg7mdp/parallel-hashmap/archive/refs/tags/v%s.zip" % parallel_hashmap_version,
|
||||
)
|
||||
|
||||
#
|
||||
# Plugins for the native code for interacting with GoDice
|
||||
#
|
||||
unity_godice_commit = "18d6823991592e4d45fcc0f22692db849dea9063"
|
||||
|
||||
unity_godice_sha = "04e6ae4155965aab3372592e04061eba1256bb6ea7ccffd0d83f27574e5b3349"
|
||||
|
||||
http_archive(
|
||||
name = "net_eagle0_unity_godice",
|
||||
sha256 = UNITY_GODICE_SHA,
|
||||
strip_prefix = "godice-framework-%s" % UNITY_GODICE_COMMIT,
|
||||
sha256 = unity_godice_sha,
|
||||
strip_prefix = "godice-framework-%s" % unity_godice_commit,
|
||||
urls = [
|
||||
"https://github.com/nolen777/godice-framework/archive/%s.zip" % UNITY_GODICE_COMMIT,
|
||||
"https://github.com/nolen777/godice-framework/archive/%s.zip" % unity_godice_commit,
|
||||
],
|
||||
)
|
||||
|
||||
#
|
||||
# Toolchain Registration
|
||||
#
|
||||
|
||||
register_toolchains(
|
||||
"//tools:unused_dependency_checker_error_and_opts_toolchain",
|
||||
"@rules_scala//testing:scalatest_toolchain",
|
||||
)
|
||||
|
||||
# Set dev_dependency so we can turn this off for swift MacOS builds
|
||||
register_toolchains(
|
||||
"@llvm_toolchain//:all",
|
||||
"@llvm_toolchain_linux//:all",
|
||||
dev_dependency = True,
|
||||
)
|
||||
|
||||
Generated
+113
-3820
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,51 @@
|
||||
# This file marks the root of the Bazel workspace.
|
||||
# See MODULE.bazel for external dependencies and setup.
|
||||
workspace(name = "net_eagle0")
|
||||
|
||||
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
|
||||
|
||||
#
|
||||
# Scala support
|
||||
#
|
||||
|
||||
scala_version = "2.13.14"
|
||||
|
||||
#rules_scala_version = "6.6.0"
|
||||
|
||||
#rules_scala_sha = "e734eef95cf26c0171566bdc24d83bd82bdaf8ca7873bec6ce9b0d524bdaf05d"
|
||||
|
||||
#http_archive(
|
||||
# name = "io_bazel_rules_scala",
|
||||
# sha256 = rules_scala_sha,
|
||||
# strip_prefix = "rules_scala-%s" % rules_scala_version,
|
||||
# url = "https://github.com/bazelbuild/rules_scala/releases/download/v%s/rules_scala-v%s.tar.gz" % (rules_scala_version, rules_scala_version),
|
||||
#)
|
||||
|
||||
# Using a commit from master to get 2.13.14 support. Restore the commented-out lines above with a new
|
||||
# release version when one is cut.
|
||||
rules_scala_commit = "e53a43bf48f10a5906b3e91c21798281cec1b334"
|
||||
|
||||
rules_scala_sha = "b4fd903724d084d9d9f45e17fc22391bda745bf0574f8934d38a9c1c2fc18834"
|
||||
|
||||
http_archive(
|
||||
name = "io_bazel_rules_scala",
|
||||
sha256 = rules_scala_sha,
|
||||
strip_prefix = "rules_scala-%s" % rules_scala_commit,
|
||||
url = "https://github.com/bazelbuild/rules_scala/archive/%s.zip" % rules_scala_commit,
|
||||
)
|
||||
|
||||
load("@io_bazel_rules_scala//:scala_config.bzl", "scala_config")
|
||||
|
||||
scala_config(scala_version = scala_version)
|
||||
|
||||
load("//tools:toolchains.bzl", "scala_register_toolchains")
|
||||
|
||||
scala_register_toolchains()
|
||||
|
||||
load("@io_bazel_rules_scala//scala:scala.bzl", "scala_repositories")
|
||||
|
||||
scala_repositories()
|
||||
|
||||
load("@io_bazel_rules_scala//testing:scalatest.bzl", "scalatest_repositories", "scalatest_toolchain")
|
||||
|
||||
scalatest_repositories()
|
||||
|
||||
scalatest_toolchain()
|
||||
|
||||
-128
@@ -1,128 +0,0 @@
|
||||
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load", "oci_push")
|
||||
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
|
||||
|
||||
#
|
||||
# Eagle Server Docker Image
|
||||
#
|
||||
# Build: bazel build //ci:eagle_server_image
|
||||
# Load: bazel run //ci:eagle_server_load
|
||||
# Push: bazel run //ci:eagle_server_push
|
||||
#
|
||||
|
||||
# Package the deploy JAR
|
||||
pkg_tar(
|
||||
name = "eagle_server_jar_layer",
|
||||
srcs = ["//src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar"],
|
||||
package_dir = "/app",
|
||||
)
|
||||
|
||||
# Package the game resources needed at runtime
|
||||
pkg_tar(
|
||||
name = "eagle_resources_layer",
|
||||
srcs = [
|
||||
"//src/main/resources/net/eagle0/eagle:beasts",
|
||||
"//src/main/resources/net/eagle0/eagle:game_parameters",
|
||||
"//src/main/resources/net/eagle0/eagle:headshots",
|
||||
"//src/main/resources/net/eagle0/eagle:heroes",
|
||||
"//src/main/resources/net/eagle0/eagle:province_map",
|
||||
"//src/main/resources/net/eagle0/eagle:settings",
|
||||
],
|
||||
package_dir = "/app/resources",
|
||||
)
|
||||
|
||||
oci_image(
|
||||
name = "eagle_server_image",
|
||||
base = "@eclipse_temurin_17_linux_amd64",
|
||||
entrypoint = [
|
||||
"java",
|
||||
"-Xmx4g",
|
||||
"-XX:+UseG1GC",
|
||||
"-jar",
|
||||
"/app/eagle_server_deploy.jar",
|
||||
],
|
||||
env = {
|
||||
"JAVA_OPTS": "-Xmx4g -XX:+UseG1GC",
|
||||
},
|
||||
exposed_ports = ["40032/tcp"],
|
||||
tars = [
|
||||
":eagle_server_jar_layer",
|
||||
":eagle_resources_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
)
|
||||
|
||||
# Load into Docker locally: bazel run //ci:eagle_server_load
|
||||
oci_load(
|
||||
name = "eagle_server_load",
|
||||
image = ":eagle_server_image",
|
||||
repo_tags = ["eagle0/eagle-server:latest"],
|
||||
)
|
||||
|
||||
# Push to DigitalOcean Container Registry
|
||||
oci_push(
|
||||
name = "eagle_server_push",
|
||||
image = ":eagle_server_image",
|
||||
repository = "registry.digitalocean.com/eagle0/eagle-server",
|
||||
)
|
||||
|
||||
#
|
||||
# Shardok Server Docker Image
|
||||
#
|
||||
# Build: bazel build //ci:shardok_server_image
|
||||
# Load: bazel run //ci:shardok_server_load
|
||||
# Push: bazel run //ci:shardok_server_push
|
||||
#
|
||||
|
||||
# Package the Shardok binary
|
||||
pkg_tar(
|
||||
name = "shardok_binary_layer",
|
||||
srcs = ["//src/main/cpp/net/eagle0/shardok:shardok-server"],
|
||||
package_dir = "/app",
|
||||
)
|
||||
|
||||
# Package the Shardok resources (battalion types, settings)
|
||||
pkg_tar(
|
||||
name = "shardok_resources_layer",
|
||||
srcs = [
|
||||
"//src/main/resources/net/eagle0/shardok:battalion_types",
|
||||
"//src/main/resources/net/eagle0/shardok:settings",
|
||||
],
|
||||
package_dir = "/app/resources",
|
||||
)
|
||||
|
||||
# Package the converted maps
|
||||
pkg_tar(
|
||||
name = "shardok_maps_layer",
|
||||
srcs = ["//src/main/resources/net/eagle0/shardok/maps"],
|
||||
package_dir = "/app/resources/maps",
|
||||
)
|
||||
|
||||
oci_image(
|
||||
name = "shardok_server_image",
|
||||
base = "@ubuntu_24_04_linux_amd64",
|
||||
entrypoint = ["/app/shardok-server"],
|
||||
exposed_ports = [
|
||||
"40042/tcp",
|
||||
"40052/tcp",
|
||||
],
|
||||
tars = [
|
||||
":shardok_binary_layer",
|
||||
":shardok_resources_layer",
|
||||
":shardok_maps_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
)
|
||||
|
||||
# Load into Docker locally: bazel run //ci:shardok_server_load
|
||||
oci_load(
|
||||
name = "shardok_server_load",
|
||||
image = ":shardok_server_image",
|
||||
repo_tags = ["eagle0/shardok-server:latest"],
|
||||
)
|
||||
|
||||
# Push to DigitalOcean Container Registry
|
||||
oci_push(
|
||||
name = "shardok_server_push",
|
||||
image = ":shardok_server_image",
|
||||
repository = "registry.digitalocean.com/eagle0/shardok-server",
|
||||
)
|
||||
+2
-1
@@ -1 +1,2 @@
|
||||
UNITY_VERSION='6000.3.0f1'
|
||||
|
||||
UNITY_VERSION='6000.0.32f1'
|
||||
@@ -1,47 +0,0 @@
|
||||
# Docker Compose for local testing of production images
|
||||
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:shardok_server_load
|
||||
# Run: docker compose -f docker-compose.prod.yml up
|
||||
|
||||
services:
|
||||
eagle:
|
||||
image: eagle0/eagle-server:latest
|
||||
container_name: eagle-server
|
||||
ports:
|
||||
- "40032:40032"
|
||||
environment:
|
||||
# Eagle server configuration
|
||||
EAGLE_GRPC_PORT: "40032"
|
||||
SHARDOK_HOST: "shardok"
|
||||
SHARDOK_PORT: "40042"
|
||||
# Resource paths (relative to /app in container)
|
||||
EAGLE_RESOURCES_PATH: "/app/resources"
|
||||
volumes:
|
||||
# Mount saves directory for persistence
|
||||
- ./saves:/app/saves
|
||||
depends_on:
|
||||
- shardok
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "nc", "-z", "localhost", "40032"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
shardok:
|
||||
image: eagle0/shardok-server:latest
|
||||
container_name: shardok-server
|
||||
ports:
|
||||
- "40042:40042"
|
||||
- "40052:40052"
|
||||
environment:
|
||||
# Shardok server configuration
|
||||
SHARDOK_RESOURCES_PATH: "/app/resources"
|
||||
SHARDOK_MAPS_PATH: "/app/resources/maps"
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "nc", "-z", "localhost", "40042"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
@@ -1,280 +0,0 @@
|
||||
# CommandProto Usage Analysis in shardok/ai
|
||||
|
||||
This document analyzes all remaining usages of `CommandProto` (protocol buffer representation) in the AI code and identifies opportunities to eliminate proto conversion by using `ShardokCommand` directly.
|
||||
|
||||
## Summary
|
||||
|
||||
**Total CommandProto usages found:** 42 locations across 9 files
|
||||
|
||||
**Eliminated:** 6 usages (14%) - ✅ **Phase 1 Complete**
|
||||
**Can be eliminated:** ~14 usages (33%)
|
||||
**Must keep (for now):** ~22 usages (53%)
|
||||
|
||||
---
|
||||
|
||||
## Files with CommandProto Usage
|
||||
|
||||
### 1. AICommandFilter.cpp (6 usages) - ✅ **COMPLETED** (PR #4505)
|
||||
**Location:** Lines 146, 189, 252, 356, 387, 428
|
||||
|
||||
**Original usage:**
|
||||
```cpp
|
||||
const auto cmdProto = cmd.GetCommandProto();
|
||||
if (!cmdProto.has_target()) { ... }
|
||||
const auto& targetCoords = cmdProto.target();
|
||||
if (!cmdProto.has_actor()) { ... }
|
||||
const auto unitId = cmdProto.actor().value();
|
||||
```
|
||||
|
||||
**Replaced with:**
|
||||
```cpp
|
||||
const int targetRow = cmd.GetTargetRow();
|
||||
const int targetCol = cmd.GetTargetColumn();
|
||||
if (targetRow < 0 || targetCol < 0) {
|
||||
throw ShardokInternalErrorException("Command missing required target");
|
||||
}
|
||||
const Coords targetCoords(targetRow, targetCol);
|
||||
|
||||
const int actorId = cmd.GetActorUnitId();
|
||||
if (actorId < 0) {
|
||||
throw ShardokInternalErrorException("Command missing required actor");
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** ✅ **ELIMINATED** - Replaced with direct accessors + exception handling
|
||||
**Impact:** Eliminated 6 proto conversions in hot path (command filtering)
|
||||
**Completed:** Phase 1, PR #4505
|
||||
|
||||
---
|
||||
|
||||
### 2. ShardokAIClient.cpp (8 usages)
|
||||
**Location:** Lines 83, 86, 87, 102, 105, 237, 261, 311, 356
|
||||
|
||||
**Usage breakdown:**
|
||||
|
||||
#### a) Command validation (lines 83-87)
|
||||
```cpp
|
||||
void CheckCommand(const CommandProto &realDescriptor, const CommandProto &guessedDescriptor) {
|
||||
differencer.IgnoreField(CommandProto::descriptor()->FindFieldByNumber(
|
||||
CommandProto::kFollowUpCommandTypesFieldNumber));
|
||||
```
|
||||
**Status:** ❌ **MUST KEEP** - Uses protobuf reflection for comparison
|
||||
**Reason:** Comparing proto messages for correctness checking requires proto API
|
||||
|
||||
#### b) GetAvailableCommandProtos calls (lines 105, 356)
|
||||
```cpp
|
||||
const auto guessedCommands = guessedEngine.GetAvailableCommandProtos(playerId, false);
|
||||
if (const auto &availableCommands = engine.GetAvailableCommandProtos(playerId, false);
|
||||
```
|
||||
**Status:** ✅ **CAN REPLACE** - Should use `GetAvailableCommandsForAIPlayer()` instead
|
||||
**Impact:** This is a major conversion point - converts entire command list to protos
|
||||
**Priority:** HIGH (converts all commands to proto unnecessarily)
|
||||
|
||||
#### c) Strategy selector methods (lines 102, 237, 261, 311)
|
||||
```cpp
|
||||
const vector<CommandProto> &realAvailableCommands) const -> CommandChoiceResults
|
||||
```
|
||||
**Status:** ✅ **CAN REPLACE** - Depends on fixing strategy selector signatures
|
||||
**Priority:** MEDIUM (depends on other refactors)
|
||||
|
||||
---
|
||||
|
||||
### 3. IterativeDeepeningAI.cpp/hpp (4 usages)
|
||||
**Location:** Lines 41, 272 (cpp), 73, 96 (hpp)
|
||||
|
||||
**Current usage:**
|
||||
```cpp
|
||||
const std::vector<CommandProto>& commands,
|
||||
```
|
||||
|
||||
**Status:** ✅ **CAN REPLACE** - These methods should accept `CommandListSPtr` instead
|
||||
**Impact:** Major - this is the main AI search algorithm
|
||||
**Priority:** HIGH (core AI algorithm)
|
||||
|
||||
**Note:** IterativeDeepeningAI already receives commands as proto vectors. The conversion happens upstream at the entry point. Need to trace back to find where `GetAvailableCommandProtos` is called.
|
||||
|
||||
---
|
||||
|
||||
### 4. AIFleeDecisionCalculator.cpp/hpp (6 usages)
|
||||
**Location:** Lines 17, 38, 39, 62, 63 (hpp), 18, 19, 137, 138 (cpp)
|
||||
|
||||
**Current usage:**
|
||||
```cpp
|
||||
const vector<CommandProto>& availableCommands,
|
||||
const vector<CommandProto>::const_iterator& fleeCommand,
|
||||
```
|
||||
|
||||
**Status:** ✅ **CAN REPLACE** - Should use `CommandListSPtr` and indices instead
|
||||
**Impact:** Flee decision logic could avoid proto conversion
|
||||
**Priority:** MEDIUM
|
||||
|
||||
---
|
||||
|
||||
### 5. AIAttackerStrategySelector.cpp/hpp (2 usages)
|
||||
**Location:** Line 30 in both files
|
||||
|
||||
**Current usage:**
|
||||
```cpp
|
||||
const vector<CommandProto>& availableCommands) -> AIStrategy
|
||||
```
|
||||
|
||||
**Status:** ⚠️ **PARTIALLY REPLACEABLE** - Currently doesn't use the commands parameter
|
||||
**Current implementation:**
|
||||
```cpp
|
||||
const vector<CommandProto>& /*availableCommands*/) -> AIStrategy {
|
||||
// Parameter is commented out - not used!
|
||||
return AIStrategy::DEFAULT;
|
||||
}
|
||||
```
|
||||
**Priority:** LOW (parameter unused, but signature should be consistent)
|
||||
|
||||
---
|
||||
|
||||
### 6. AICommandEvaluator.hpp (1 usage)
|
||||
**Location:** Line 27
|
||||
|
||||
**Current usage:**
|
||||
```cpp
|
||||
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
|
||||
```
|
||||
|
||||
**Status:** ⚠️ **CHECK USAGE** - Type alias, need to check if used
|
||||
**Priority:** LOW (just a type alias)
|
||||
|
||||
---
|
||||
|
||||
### 7. AIScoreCalculator.hpp (1 usage)
|
||||
**Location:** Line 24
|
||||
|
||||
**Current usage:**
|
||||
```cpp
|
||||
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
|
||||
```
|
||||
|
||||
**Status:** ⚠️ **CHECK USAGE** - Type alias, need to check if used
|
||||
**Priority:** LOW (just a type alias)
|
||||
|
||||
---
|
||||
|
||||
### 8. AIWaterCrossingCommandChooser.hpp (1 usage)
|
||||
**Location:** Line 20
|
||||
|
||||
**Current usage:**
|
||||
```cpp
|
||||
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
|
||||
```
|
||||
|
||||
**Status:** ⚠️ **CHECK USAGE** - Type alias, need to check if used
|
||||
**Priority:** LOW (just a type alias)
|
||||
|
||||
---
|
||||
|
||||
## Key Conversion Points (Entry Points)
|
||||
|
||||
### ShardokEngine::GetAvailableCommandProtos()
|
||||
This method converts the entire command list from `CommandListSPtr` to `vector<CommandProto>`.
|
||||
|
||||
**Current flow:**
|
||||
```
|
||||
ShardokEngine::GetAvailableCommandsForAIPlayer() → CommandListSPtr
|
||||
↓ (conversion)
|
||||
ShardokEngine::GetAvailableCommandProtos() → vector<CommandProto>
|
||||
↓
|
||||
AI algorithms (IterativeDeepeningAI, etc.)
|
||||
```
|
||||
|
||||
**Desired flow:**
|
||||
```
|
||||
ShardokEngine::GetAvailableCommandsForAIPlayer() → CommandListSPtr
|
||||
↓ (no conversion!)
|
||||
AI algorithms use CommandSPtr directly
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recommendations by Priority
|
||||
|
||||
### HIGH Priority (Performance-critical hot paths)
|
||||
|
||||
1. **AICommandFilter.cpp (6 usages)**
|
||||
- Replace `cmd.GetCommandProto()` with direct accessor methods
|
||||
- Use `GetActorUnitId()`, `GetTargetRow()`, `GetTargetColumn()`
|
||||
- Impact: Eliminates 6 proto conversions per filtered command
|
||||
|
||||
2. **ShardokAIClient.cpp - GetAvailableCommandProtos calls**
|
||||
- Replace calls to `GetAvailableCommandProtos()` with `GetAvailableCommandsForAIPlayer()`
|
||||
- Impact: Eliminates conversion of entire command list
|
||||
|
||||
3. **IterativeDeepeningAI**
|
||||
- Change signature from `vector<CommandProto>` to `CommandListSPtr`
|
||||
- Impact: Main AI search algorithm avoids proto conversion
|
||||
|
||||
### MEDIUM Priority
|
||||
|
||||
4. **AIFleeDecisionCalculator**
|
||||
- Change to use `CommandListSPtr` and indices
|
||||
- Impact: Flee decision logic avoids proto
|
||||
|
||||
5. **ShardokAIClient strategy methods**
|
||||
- Update signatures to use `CommandListSPtr`
|
||||
- Cascades to strategy selectors
|
||||
|
||||
### LOW Priority
|
||||
|
||||
6. **Type aliases**
|
||||
- Remove unused `using CommandProto` declarations
|
||||
- Clean up imports
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Low-hanging fruit (AICommandFilter) - ✅ **COMPLETED** (PR #4505)
|
||||
- ✅ Replaced 6 proto conversions with direct accessor calls
|
||||
- ✅ Added exception handling for missing actor/target data
|
||||
- ✅ No signature changes needed
|
||||
- ✅ Immediate performance benefit
|
||||
- **PR:** #4505
|
||||
|
||||
### Phase 2: Entry point (ShardokAIClient)
|
||||
- Replace `GetAvailableCommandProtos()` calls with `GetAvailableCommandsForAIPlayer()`
|
||||
- Update method signatures in ShardokAIClient
|
||||
|
||||
### Phase 3: Core AI (IterativeDeepeningAI)
|
||||
- Change IterativeDeepeningAI to accept `CommandListSPtr`
|
||||
- This is the biggest change but has highest impact
|
||||
|
||||
### Phase 4: Supporting systems
|
||||
- Update AIFleeDecisionCalculator
|
||||
- Update strategy selectors
|
||||
- Clean up type aliases
|
||||
|
||||
### Phase 5: Validation code
|
||||
- Keep proto-based validation as-is (uses reflection)
|
||||
- Consider if validation is still needed in production
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **MCTS already converted**: The MCTS code path already uses `CommandListSPtr` directly
|
||||
- **Proto still needed**: For serialization/network communication (not in AI hot path)
|
||||
- **Validation**: Proto comparison in CheckCommand() should remain (uses proto reflection)
|
||||
|
||||
---
|
||||
|
||||
## Estimated Impact
|
||||
|
||||
**Proto conversions eliminated:** ~20-25 per command choice
|
||||
**Performance gain:** Eliminates hundreds of allocations per AI decision
|
||||
**Code simplification:** Removes proto conversion layer from AI
|
||||
|
||||
**Before:**
|
||||
```
|
||||
Command → Proto → AI Decision
|
||||
```
|
||||
|
||||
**After:**
|
||||
```
|
||||
Command → AI Decision (direct)
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,334 +0,0 @@
|
||||
# Deproto Migration Plan
|
||||
|
||||
## Vision
|
||||
|
||||
**Protocol buffers should only be used at the edges** — for network serialization (gRPC) and disk persistence. Inside the Eagle game engine, all logic should operate on native Scala models.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ GRPC BOUNDARY │
|
||||
│ EagleServiceImpl.scala ←→ Proto Messages ←→ Unity Client │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
GameStateConverter
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ SCALA ENGINE │
|
||||
│ │
|
||||
│ GameStateC ───→ Actions ───→ ActionResultT ───→ New GameStateC │
|
||||
│ ↑ │ │
|
||||
│ │ (Pure Scala models) │ │
|
||||
│ └───────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ HeroC, FactionC, ProvinceC, BattalionC, ArmyC, etc. │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
GameStateConverter
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ PERSISTENCE BOUNDARY │
|
||||
│ GameHistory.scala ←→ Proto Messages ←→ File/Database │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
### Completed Phases
|
||||
|
||||
| Phase | Status | Summary |
|
||||
|-------|--------|---------|
|
||||
| Phase 1: GameStateC | **Complete** | Scala `GameState` model with 22 fields |
|
||||
| Phase 2: EngineImpl | **Complete** | Holds Scala `GameState` internally |
|
||||
| Phase 3: GameHistory | **Complete** | `stateAfter` returns Scala GameState |
|
||||
| Phase 4: ActionResultT | **Complete** | All 59 actions return `ActionResultT` |
|
||||
| Phase 5: Action Base Classes | **Complete** | All `RandomSequentialResultsAction` and `DeterministicSingleResultAction` converted to T-type base classes |
|
||||
| Phase 5b: Base Class Cleanup | **Complete** | `RandomSequentialResultsAction` and `DeterministicSingleResultAction` deleted |
|
||||
| Phase 5c: RoundPhaseAdvancer Actions | **Complete** | All actions called by RoundPhaseAdvancer accept Scala GameState |
|
||||
| Phase 5d: RoundPhaseAdvancer Itself | **Complete** | RoundPhaseAdvancer.checkForPhaseAdvancement takes Scala GameState |
|
||||
|
||||
### Phase 5c/5d Progress (Complete)
|
||||
|
||||
`RoundPhaseAdvancer.checkForPhaseAdvancement` now accepts Scala `GameState` and `ActionResultApplier` directly (PR #4677).
|
||||
|
||||
| Action | PR | Status |
|
||||
|--------|-----|--------|
|
||||
| `PrisonerExchangeAction` | #4670 | ✅ Merged |
|
||||
| `PerformForcedTurnBackAction` | #4671 | ✅ Merged |
|
||||
| `PerformHeroDeparturesAction` | #4672 | ✅ Merged |
|
||||
| `RequestFreeForAllBattlesAction` | #4673 | ✅ Merged |
|
||||
| `EndPlayerCommandsPhaseAction` | #4674 | ✅ Merged |
|
||||
| `EndDiplomacyResolutionPhaseAction` | #4675 | ✅ Merged |
|
||||
| `RoundPhaseAdvancer` itself | #4677 | ✅ Merged |
|
||||
|
||||
### EngineImpl Progress
|
||||
|
||||
| Change | PR | Status |
|
||||
|--------|-----|--------|
|
||||
| `recursiveTransform` deleted | #4677 | ✅ Merged |
|
||||
| `recursiveTransformT` uses `RandomStateTSequencer` | #4677 | ✅ Merged |
|
||||
|
||||
### Current Architecture
|
||||
|
||||
**ActionResultT Production (100% Complete):**
|
||||
- All actions produce `ActionResultT`
|
||||
- Conversion to `ActionResultProto` happens via `ActionResultProtoConverter.toProto()`
|
||||
- No direct `ActionResultProto` construction outside the converter
|
||||
|
||||
**ActionResultProto Consumption (Next Target):**
|
||||
- `ActionResultProtoApplierImpl` - applies proto results to proto GameState
|
||||
- `RoundPhaseAdvancer` - calls converter, passes protos to applier
|
||||
- `InMemoryHistory` / `PersistedHistory` - stores proto results
|
||||
- Service layer (`GameController`, `GamesManager`, etc.) - uses proto for client communication
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Migrate to ActionResultT Consumers
|
||||
|
||||
### Objective
|
||||
|
||||
Eliminate internal consumption of `ActionResultProto`. Everything inside the engine should work with `ActionResultT`.
|
||||
|
||||
### Current Flow (Proto-Heavy)
|
||||
```
|
||||
Action.execute()
|
||||
→ ActionResultT
|
||||
→ ActionResultProtoConverter.toProto()
|
||||
→ ActionResultProto
|
||||
→ ActionResultProtoApplierImpl.applyActionResults()
|
||||
→ GameStateProto
|
||||
→ GameStateConverter.fromProto()
|
||||
→ GameStateC
|
||||
```
|
||||
|
||||
### Target Flow (T-Types Throughout)
|
||||
```
|
||||
Action.execute()
|
||||
→ ActionResultT
|
||||
→ ActionResultApplier.applyActionResults()
|
||||
→ GameStateC
|
||||
|
||||
(Proto conversion only at boundaries)
|
||||
```
|
||||
|
||||
### Key Files to Convert
|
||||
|
||||
**Tier 1 - Core Applier:** ✅ **Complete**
|
||||
```
|
||||
src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultApplierImpl.scala
|
||||
```
|
||||
`ActionResultApplier` applies `ActionResultT` directly to Scala `GameState`. The legacy `ActionResultTApplierImpl` wraps it and converts to/from proto for callers that still need proto types.
|
||||
|
||||
**Tier 2 - RoundPhaseAdvancer:** ✅ **Complete**
|
||||
```
|
||||
src/main/scala/net/eagle0/eagle/library/RoundPhaseAdvancer.scala
|
||||
```
|
||||
Now accepts Scala `GameState` and `ActionResultApplier`. Only converts to proto lazily for `AvailableCommandsFactory` calls.
|
||||
|
||||
**Tier 3 - Sequencers:**
|
||||
```
|
||||
src/main/scala/net/eagle0/eagle/library/actions/impl/common/RandomStateTSequencer.scala
|
||||
src/main/scala/net/eagle0/eagle/library/actions/impl/common/RandomStateProtoSequencer.scala
|
||||
```
|
||||
Modify `RandomStateTSequencer` to thread Scala `GameState` throughout (currently converts to proto internally). Then evaluate whether `RandomStateProtoSequencer` is still needed at all.
|
||||
|
||||
**Current State**: `RandomStateTSequencer` accepts Scala `GameState` via its `apply()` method but internally converts to proto. All callback methods (`withRandomActionResult`, `withActionResults`, etc.) pass `GameStateProto` to callers, forcing actions that use the sequencer to work with proto types internally.
|
||||
|
||||
**Target State**: Create a fully protoless sequencer where:
|
||||
1. `lastState` returns Scala `GameState` (not `lastStateProto`)
|
||||
2. All callback methods pass Scala `GameState` to callers
|
||||
3. Actions using the sequencer can be fully protoless
|
||||
|
||||
**Migration Path**:
|
||||
1. Add `lastState: GameState` method alongside `lastStateProto` (non-breaking)
|
||||
2. Add parallel callback methods that pass Scala GameState (e.g., `withScalaActionResult`)
|
||||
3. Migrate actions one by one to use the new Scala-based callbacks
|
||||
4. Once all actions migrated, deprecate/remove proto-based callbacks
|
||||
5. Remove `lastStateProto` once no longer used
|
||||
|
||||
**RandomStateSequencer Migration Progress** (PR #4679 introduced protoless `RandomStateSequencer`):
|
||||
|
||||
| Action | Status |
|
||||
|--------|--------|
|
||||
| `TruceTurnBackPhaseAction` | ✅ Migrated (PR #4680) |
|
||||
| `EndHandleRiotsPhaseAction` | ✅ Migrated (PR #4684) |
|
||||
| `PerformVassalCommandsPhaseAction` | ✅ Migrated |
|
||||
| `PerformVassalDefenseDecisionsAction` | ✅ Migrated |
|
||||
| `EndVassalCommandsPhaseAction` | ✅ Migrated |
|
||||
| `PerformReconResolutionAction` | ✅ Migrated |
|
||||
| `NewRoundAction` | ✅ Migrated (PR #4698) |
|
||||
| `EndBattleAftermathPhaseAction` | ✅ Migrated (PR #4699) |
|
||||
| `EndDiplomacyResolutionPhaseAction` | ✅ Migrated |
|
||||
| `PerformUnaffiliatedHeroesAction` | ✅ Migrated |
|
||||
| `EngineImpl.recursiveTransformT` | ✅ Migrated (PR #4704) |
|
||||
| `ProtolessSequentialResultsActionWrapper` | ✅ Migrated (PR #4705) |
|
||||
| `LegacyRandomStateTSequencer` | ✅ **Deleted** (PR #4705) |
|
||||
|
||||
**TCommandFactory Extraction** (PR #4684):
|
||||
|
||||
To enable lightweight mocking of command creation in tests, `TCommandFactory` trait was extracted from `CommandFactory`. This allows tests to mock just the `makeTCommand` method without pulling in all 40+ command dependencies that `CommandFactory` requires.
|
||||
|
||||
- `TCommandFactory` - lightweight trait with just `makeTCommand`
|
||||
- `CommandFactory extends TCommandFactory` - maintains backward compatibility
|
||||
- Actions accepting command factories now use `TCommandFactory` type for better testability
|
||||
|
||||
**Tier 4 - History APIs:**
|
||||
```
|
||||
src/main/scala/net/eagle0/eagle/service/InMemoryHistory.scala
|
||||
src/main/scala/net/eagle0/eagle/service/PersistedHistory.scala
|
||||
```
|
||||
Change APIs to vend Scala `GameState` and `ActionResultT` instead of proto versions. `PersistedHistory` converts to proto internally for disk persistence; `InMemoryHistory` doesn't need proto at all.
|
||||
|
||||
### ActionResultProto Consumer Inventory
|
||||
|
||||
| File | Usage | Status |
|
||||
|------|-------|--------|
|
||||
| `ActionResultApplierImpl.scala` | Applies ActionResultT to Scala GameState | ✅ **Complete** |
|
||||
| `ActionResultTApplierImpl.scala` | Legacy wrapper - converts to/from proto | Keep until all callers migrated |
|
||||
| `RoundPhaseAdvancer.scala` | Uses Scala GameState | ✅ **Complete** |
|
||||
| `RandomStateSequencer.scala` | Threads Scala GameState | ✅ **Complete** |
|
||||
| `VigorXPApplier.scala` | Has both proto and Scala methods | Scala method exists, delete proto method when unused |
|
||||
| `PerformForcedTurnBackAction.scala` | Fully protoless | ✅ **Complete** |
|
||||
| `ResolveBattleAction.scala` | Heavy proto usage | Blocked by proto dependencies |
|
||||
| `InMemoryHistory.scala` | Stores proto results | Pending - vend Scala types |
|
||||
| `PersistedHistory.scala` | Stores proto results | Pending - vend Scala types, convert for disk |
|
||||
| `GameController.scala` | Uses proto for client communication | Keep proto (gRPC boundary) |
|
||||
|
||||
### Remaining Proto Usage in Actions
|
||||
|
||||
**Progress: 46 of 52 action files (88%) are fully protoless.**
|
||||
|
||||
The following 6 actions still have proto usage:
|
||||
|
||||
| Action | Proto Usages | Blocker | Effort |
|
||||
|--------|--------------|---------|--------|
|
||||
| `ResolveBattleAction` | 24 | Shardok interface, complex battle logic | High |
|
||||
| `PerformVassalCommandsPhaseAction` | 3 | `CommandChoiceHelpers` takes proto GameState | Medium |
|
||||
| `EndHandleRiotsPhaseAction` | 2 | `CommandChoiceHelpers` takes proto GameState | Medium |
|
||||
| `PerformVassalDefenseDecisionsAction` | 2 | `CommandChoiceHelpers` takes proto GameState | Medium |
|
||||
| `EndVassalCommandsPhaseAction` | 1 | `CommandChoiceHelpers` takes proto GameState | Medium |
|
||||
| `NewRoundAction` | 1 | `ChronicleEventGenerator` returns proto | Medium |
|
||||
|
||||
**Deleted Dead Code:**
|
||||
- `UnaffiliatedHeroMovedAction` - Was never called; `PerformUnaffiliatedHeroesAction.heroMovedResult` constructs `ActionResultC` directly
|
||||
- `HeroBackstoryUpdateActionGenerator.fromGameState` - Dead method that converted proto to Scala; only `apply(GameState)` is used
|
||||
|
||||
**Note**: `PerformReconResolutionAction` and `EndBattleAftermathPhaseAction` are now fully protoless after:
|
||||
1. Migrating `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` to use Scala `ProvinceView`
|
||||
2. Adding Scala overload of `ProvinceViewFilter.withdrawnFromProvinceView`
|
||||
|
||||
### Estimated Effort (Remaining)
|
||||
|
||||
| Component | Lines | Complexity | Blocks |
|
||||
|-----------|-------|------------|--------|
|
||||
| `CommandChoiceHelpers` to Scala | ~2000 | High | 4 vassal actions |
|
||||
| `ResolveBattleAction` refactor | ~500 | High | 1 action (complex) |
|
||||
| `ChronicleEventGenerator` to Scala | ~400 | Medium | 1 action |
|
||||
| History API updates | ~100 | Low | - |
|
||||
| **Total Remaining** | **~3000** | | |
|
||||
|
||||
### Progress Summary
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Action files fully protoless | 46 / 52 (88%) |
|
||||
| Proto usages in remaining actions | 33 total |
|
||||
| Biggest blocker | `ResolveBattleAction` (24 usages) |
|
||||
| Second biggest blocker | `CommandChoiceHelpers` (blocks 4 actions) |
|
||||
|
||||
### Validation
|
||||
- [x] `ActionResultApplier` created and tested
|
||||
- [x] `RandomStateSequencer` threads Scala GameState throughout
|
||||
- [x] `RoundPhaseAdvancer` uses T-types internally
|
||||
- [x] `ProvinceViewFilter` has Scala overload for server-side use (PR #4752)
|
||||
- [x] `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` use Scala `ProvinceView`
|
||||
- [x] `ProvinceViewFilter.withdrawnFromProvinceView` has Scala overload
|
||||
- [ ] `ProvinceViewFilter` faction-filtered views use Scala types
|
||||
- [ ] `CommandChoiceHelpers` uses Scala types
|
||||
- [ ] History APIs vend Scala types
|
||||
- [ ] No `ActionResultProtoConverter.toProto()` calls except at persistence/gRPC boundaries
|
||||
- [ ] All tests pass
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Clean Up Legacy Utilities
|
||||
|
||||
### Objective
|
||||
Remove remaining direct proto imports from utility classes.
|
||||
|
||||
### Files to Modify
|
||||
|
||||
| File | Status |
|
||||
|------|--------|
|
||||
| `CommandChoiceHelpers.scala` | Accepts proto `GameState`; blocks full deproto of `PerformVassalCommandsPhaseAction` and `PerformVassalDefenseDecisionsAction` |
|
||||
| `LegacyProvinceUtils.scala` | Replace with `ProvinceUtils.scala` - `hasImminentRiot` added (PR #4683) |
|
||||
| `LegacyFactionUtils.scala` | Replace proto imports with `FactionT` |
|
||||
| `LegacyUnaffiliatedHeroUtils.scala` | Replace proto imports with Scala models |
|
||||
| `BattalionTypeLoader.scala` | Keep proto for file loading, convert immediately after |
|
||||
| `BeastUtils.scala` | **Complete** - now uses Scala `BeastInfo` only |
|
||||
|
||||
### View Filters (Partially Complete)
|
||||
|
||||
The view filter utilities now have Scala overloads for server-side use:
|
||||
|
||||
| File | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| `ProvinceViewFilter.scala` | **Partial** | `filteredProvinceView(ProvinceT, ScalaGameState)` added (PR #4752) |
|
||||
| `ArmyFilter.scala` | **Partial** | `filterArmy(ScalaArmy, Map[BattalionId, BattalionT], Option[FactionId])` added |
|
||||
| `BattalionViewFilter.scala` | **Complete** | Uses Scala `BattalionT` throughout |
|
||||
| `GameStateViewFilter.scala` | Pending | Uses proto types throughout |
|
||||
| `GameStateViewDiffer.scala` | Pending | Works with view protos |
|
||||
|
||||
**Unblocked Actions** (PR #4752):
|
||||
- `EndBattleAftermathPhaseAction` - can now use `filteredProvinceView(province, scalaGameState)`
|
||||
- `PerformReconResolutionAction` - can now use Scala overload
|
||||
- `GameStateFactionExtensions` - can now use `updatedReconnedProvinces` with Scala types
|
||||
|
||||
**Remaining Work**:
|
||||
- Faction-filtered `filteredProvinceView(Province, GameState, FactionId)` still uses proto types
|
||||
- `withdrawnFromProvinceView` still uses proto types
|
||||
- These are needed for client-facing views with visibility restrictions
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Verify Boundaries
|
||||
|
||||
### Objective
|
||||
Confirm protos are used correctly at boundaries — and ONLY there.
|
||||
|
||||
### Expected Proto Usage (Keep)
|
||||
- `EagleServiceImpl.scala` - gRPC boundary
|
||||
- `InMemoryHistory.scala` / `PersistedHistory.scala` - Persistence boundary
|
||||
- `*Converter.scala` - Explicit conversion utilities
|
||||
- `*Loader.scala` - File loading utilities
|
||||
|
||||
### Expected No Proto Usage (Verify)
|
||||
- `/library/actions/impl/` - Pure Scala models
|
||||
- `/library/util/` - Pure Scala models (except loaders)
|
||||
- `/model/state/` - Pure Scala models
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Persistence Format**: Currently game state is persisted as proto. Should we keep proto for persistence (good for schema evolution) or switch to a different format?
|
||||
|
||||
2. **Shardok Integration**: `ResolveBattleAction` communicates with Shardok. Should the Shardok interface use protos (external service) or Scala models?
|
||||
|
||||
3. **View Generation**: `GameStateViewDiffer` works with view protos for client updates. Views need Scala models (`ProvinceViewT`, etc.) to allow actions like `EndBattleAftermathPhaseAction` to be fully protoless. The Scala views would be converted to proto only at the gRPC boundary when sending updates to clients.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Code Quality
|
||||
- [ ] Zero proto imports in `/library/actions/` (except boundaries)
|
||||
- [ ] Zero proto imports in `/library/` utilities (except loaders)
|
||||
- [ ] `GameStateT` used throughout engine internals
|
||||
- [ ] Proto usage limited to: `EagleServiceImpl`, loaders, converters, persistence
|
||||
|
||||
### Architecture
|
||||
- [ ] Clear separation: Scala models (internal) vs Proto (boundaries)
|
||||
- [ ] Converters as the only bridge between domains
|
||||
- [ ] No "proto creep" into business logic
|
||||
@@ -1,940 +0,0 @@
|
||||
# Eagle0 Productionization Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document outlines a plan to move Eagle0's Eagle and Shardok servers from a home Mac to cloud infrastructure while maintaining a QA environment on the Mac. The architecture uses DigitalOcean (already integrated via Spaces) with containerized deployments and GitHub Actions CI/CD.
|
||||
|
||||
## Current Architecture
|
||||
|
||||
```
|
||||
Unity Client
|
||||
│
|
||||
│ gRPC/TLS (eagle0.net:443)
|
||||
▼
|
||||
nginx (home Mac, via router port forward)
|
||||
│
|
||||
├─► Eagle Server (Scala/JVM, port 40032)
|
||||
│ │
|
||||
│ │ internal gRPC (port 40042)
|
||||
│ ▼
|
||||
└─► Shardok Server (C++, port 40042/40052)
|
||||
|
||||
Storage: DigitalOcean Spaces (sfo3.digitaloceanspaces.com)
|
||||
DNS: eagle0.net → home IP
|
||||
```
|
||||
|
||||
**Key observations:**
|
||||
- Already using DigitalOcean Spaces for S3-compatible storage
|
||||
- Eagle server is a deployable JAR (`eagle_server_deploy.jar`)
|
||||
- Shardok server is a native C++ binary
|
||||
- nginx handles TLS termination and gRPC routing
|
||||
- Self-hosted GitHub Actions runner on Mac
|
||||
|
||||
---
|
||||
|
||||
## Target Architecture
|
||||
|
||||
### Production Environment (DigitalOcean)
|
||||
|
||||
```
|
||||
Unity Client
|
||||
│
|
||||
├─► eagle0.net (Production)
|
||||
│ │
|
||||
│ ▼
|
||||
│ DigitalOcean Load Balancer (TLS termination)
|
||||
│ │
|
||||
│ ▼
|
||||
│ ┌─────────────────────────────────────┐
|
||||
│ │ DigitalOcean Droplet(s) │
|
||||
│ │ ┌─────────────┬─────────────────┐ │
|
||||
│ │ │ Eagle │ Shardok │ │
|
||||
│ │ │ (Docker) │ (Docker) │ │
|
||||
│ │ │ :40032 │ :40042 │ │
|
||||
│ │ └─────────────┴─────────────────┘ │
|
||||
│ └─────────────────────────────────────┘
|
||||
│
|
||||
└─► qa.eagle0.net (QA - home Mac, unchanged)
|
||||
│
|
||||
▼
|
||||
nginx → Eagle/Shardok (current setup)
|
||||
```
|
||||
|
||||
### Environment Switching
|
||||
|
||||
The Unity client already supports configurable server URLs via the connection screen:
|
||||
- **Production:** `eagle0.net` (default)
|
||||
- **QA:** `qa.eagle0.net`
|
||||
|
||||
No client code changes needed - users can simply type the desired URL.
|
||||
|
||||
---
|
||||
|
||||
## Cloud Provider: DigitalOcean
|
||||
|
||||
### Why DigitalOcean
|
||||
|
||||
1. **Already integrated** - S3 Spaces storage at `sfo3.digitaloceanspaces.com` with credentials configured
|
||||
2. **Simple pricing** - Predictable monthly costs, no surprise bills
|
||||
3. **Good performance** - SFO3 datacenter is geographically close
|
||||
4. **Managed services** - Load balancers, managed databases if needed later
|
||||
5. **Not AWS/GCP** - Per your requirements
|
||||
|
||||
### Alternative Considered: Hetzner
|
||||
|
||||
- Cheaper for compute ($3.29/mo for 2 vCPU/4GB vs DO's $24/mo)
|
||||
- Good EU presence but less US coverage
|
||||
- No managed load balancer (need to run HAProxy/nginx yourself)
|
||||
- **Recommendation:** Start with DigitalOcean for simplicity; migrate to Hetzner later if cost becomes a concern
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure Components
|
||||
|
||||
### 1. Compute: DigitalOcean Droplets
|
||||
|
||||
#### Resource Characteristics
|
||||
|
||||
- **Eagle server:** CPU-light, possibly RAM-heavy (JVM). Should be always available.
|
||||
- **Shardok server:** Very CPU-heavy (AI algorithms). Only needed during tactical battles. Startup time <1 second.
|
||||
|
||||
#### Recommended: On-Demand Shardok (Same Droplet)
|
||||
|
||||
For current low player count, optimize for cost while maintaining availability:
|
||||
|
||||
| Component | Config | Monthly Cost |
|
||||
|-----------|--------|--------------|
|
||||
| Droplet | s-2vcpu-4gb | $24/mo |
|
||||
| Eagle | Always running | - |
|
||||
| Shardok | Started on-demand by Eagle, stopped after idle | - |
|
||||
|
||||
**How it works:**
|
||||
1. Eagle server runs 24/7 - game is always available
|
||||
2. When battle starts, Eagle launches Shardok container/process (<1s startup)
|
||||
3. After battle ends, idle timer starts (e.g., 5 minutes)
|
||||
4. On timeout, Eagle stops Shardok to free CPU
|
||||
5. If new battle starts during idle period, Shardok is already warm
|
||||
|
||||
**Shardok lifecycle management** (implement in Eagle):
|
||||
```scala
|
||||
// Pseudocode for Eagle's Shardok management
|
||||
object ShardokManager {
|
||||
private var process: Option[Process] = None
|
||||
private var idleTimer: Option[Timer] = None
|
||||
|
||||
def ensureRunning(): Unit = {
|
||||
cancelIdleTimer()
|
||||
if (process.isEmpty) {
|
||||
process = Some(startShardokProcess())
|
||||
waitForHealthCheck()
|
||||
}
|
||||
}
|
||||
|
||||
def onBattleEnd(): Unit = {
|
||||
idleTimer = Some(scheduleShutdown(5.minutes))
|
||||
}
|
||||
|
||||
private def shutdown(): Unit = {
|
||||
process.foreach(_.destroy())
|
||||
process = None
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Future Scaling Options
|
||||
|
||||
As player count grows and Shardok needs more CPU:
|
||||
|
||||
**Option A: Upgrade Droplet (Simplest)**
|
||||
|
||||
| Droplet | vCPU | RAM | Cost | Use Case |
|
||||
|---------|------|-----|------|----------|
|
||||
| s-2vcpu-4gb | 2 shared | 4 GB | $24/mo | Current (few players) |
|
||||
| s-4vcpu-8gb | 4 shared | 8 GB | $48/mo | Moderate usage |
|
||||
| c-4 | 4 dedicated | 8 GB | $84/mo | CPU-intensive battles |
|
||||
| c-8 | 8 dedicated | 16 GB | $168/mo | Multiple concurrent battles |
|
||||
|
||||
**Option B: Separate Shardok Droplet (API-driven)**
|
||||
|
||||
For heavy Shardok usage with cost optimization:
|
||||
|
||||
- **Eagle droplet:** s-1vcpu-2gb ($12/mo) - always on
|
||||
- **Shardok droplet:** Created on-demand via DigitalOcean API
|
||||
- c-4 CPU-optimized: $0.125/hour
|
||||
- c-8 CPU-optimized: $0.25/hour
|
||||
- Created when battle starts, destroyed after idle
|
||||
- 30-60s droplet creation time (acceptable if battles are requested in advance)
|
||||
|
||||
**Option C: Fly.io for Shardok (Scale-to-Zero)**
|
||||
|
||||
For true pay-per-use with fast cold starts:
|
||||
|
||||
- **Eagle:** DigitalOcean or Fly.io (~$10/mo)
|
||||
- **Shardok:** Fly.io with auto-scaling
|
||||
- Performance VMs: ~$0.0000022/second when running
|
||||
- Cold start: 2-5 seconds
|
||||
- Scales to zero when idle
|
||||
|
||||
Requires learning Fly.io but offers best cost efficiency for sporadic usage.
|
||||
|
||||
**Option D: Multiple Shardok Instances (High Scale)**
|
||||
|
||||
For many concurrent battles:
|
||||
|
||||
- **Eagle:** Dedicated droplet with more RAM
|
||||
- **Shardok pool:** Multiple Shardok containers/droplets
|
||||
- Eagle routes battles to available Shardok instances
|
||||
- Could use Kubernetes or Docker Swarm for orchestration
|
||||
|
||||
This is overkill for now but documented for future reference.
|
||||
|
||||
### 2. Load Balancer
|
||||
|
||||
**DigitalOcean Load Balancer:** $12/mo
|
||||
|
||||
- TLS termination with Let's Encrypt
|
||||
- Health checks
|
||||
- Sticky sessions (if needed)
|
||||
- Can add more droplets later for HA
|
||||
|
||||
**Alternative:** Run nginx on the droplet for $0 extra, but lose automatic failover.
|
||||
|
||||
### 3. Storage
|
||||
|
||||
**Already configured:** DigitalOcean Spaces
|
||||
- Bucket: `eagle0`
|
||||
- Region: `sfo3`
|
||||
- Used for game saves and assets
|
||||
- Cost: $5/mo base + $0.02/GB storage + $0.01/GB transfer
|
||||
|
||||
### 4. DNS
|
||||
|
||||
**Option A: DigitalOcean DNS (Recommended)**
|
||||
- Free with droplets
|
||||
- Easy integration
|
||||
- API for automated updates
|
||||
|
||||
**Option B: Keep current DNS provider**
|
||||
- Update A records manually or via script
|
||||
|
||||
**DNS Records:**
|
||||
```
|
||||
eagle0.net A <DO Load Balancer IP>
|
||||
qa.eagle0.net A <Home IP> (unchanged)
|
||||
*.eagle0.net A <DO Load Balancer IP> (wildcard for future)
|
||||
```
|
||||
|
||||
### 5. Firewall
|
||||
|
||||
**DigitalOcean Cloud Firewall:** Free
|
||||
|
||||
```
|
||||
Inbound Rules:
|
||||
- TCP 443 (HTTPS/gRPC) from anywhere → Load Balancer
|
||||
- TCP 22 (SSH) from your IP only → Droplets
|
||||
- TCP 40032 (Eagle) from Load Balancer only
|
||||
- TCP 40042 (Shardok) from Load Balancer only
|
||||
|
||||
Outbound Rules:
|
||||
- All traffic allowed (for external APIs, Spaces, etc.)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Container Strategy
|
||||
|
||||
### Dockerfiles
|
||||
|
||||
**Eagle Server** (`ci/eagle_run.Dockerfile` - already exists, needs enhancement):
|
||||
|
||||
```dockerfile
|
||||
FROM eclipse-temurin:17-jre-alpine
|
||||
|
||||
# Add non-root user
|
||||
RUN addgroup -S eagle && adduser -S eagle -G eagle
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the deploy JAR
|
||||
COPY --chown=eagle:eagle deploy/eagle_server_deploy.jar ./
|
||||
|
||||
# Copy game resources
|
||||
COPY --chown=eagle:eagle src/main/resources/net/eagle0/eagle/ ./resources/
|
||||
|
||||
USER eagle
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
||||
CMD wget -q --spider http://localhost:40032/health || exit 1
|
||||
|
||||
EXPOSE 40032
|
||||
|
||||
ENTRYPOINT ["java", "-Xmx4g", "-jar", "eagle_server_deploy.jar"]
|
||||
CMD ["--eagle-grpc-port", "40032", "--shardok-interface-remote-address", "shardok:40042", "--gpt-model-name", "gpt-4"]
|
||||
```
|
||||
|
||||
**Shardok Server** (new: `ci/shardok_run.Dockerfile`):
|
||||
|
||||
```dockerfile
|
||||
FROM ubuntu:22.04
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libstdc++6 \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Add non-root user
|
||||
RUN useradd -r -s /bin/false shardok
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the Shardok binary and dependencies
|
||||
COPY --chown=shardok:shardok bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server ./
|
||||
COPY --chown=shardok:shardok src/main/resources/net/eagle0/shardok/maps/ ./maps/
|
||||
|
||||
USER shardok
|
||||
|
||||
# Health check (need to implement gRPC health endpoint)
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
||||
CMD ./shardok-server --health-check || exit 1
|
||||
|
||||
EXPOSE 40042 40052
|
||||
|
||||
ENTRYPOINT ["./shardok-server"]
|
||||
```
|
||||
|
||||
**Docker Compose** (new: `docker-compose.prod.yml`):
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
eagle:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ci/eagle_run.Dockerfile
|
||||
image: eagle0/eagle-server:${VERSION:-latest}
|
||||
ports:
|
||||
- "40032:40032"
|
||||
environment:
|
||||
- SHARDOK_ADDRESS=shardok:40042
|
||||
- GPT_MODEL_NAME=${GPT_MODEL_NAME:-gpt-4}
|
||||
- JAVA_OPTS=-Xmx4g -XX:+UseG1GC
|
||||
depends_on:
|
||||
- shardok
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "100m"
|
||||
max-file: "5"
|
||||
|
||||
shardok:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ci/shardok_run.Dockerfile
|
||||
image: eagle0/shardok-server:${VERSION:-latest}
|
||||
ports:
|
||||
- "40042:40042"
|
||||
- "40052:40052"
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "100m"
|
||||
max-file: "5"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build Pipeline
|
||||
|
||||
### Build Artifacts
|
||||
|
||||
The build process produces:
|
||||
1. **Eagle JAR:** `bazel-bin/src/main/scala/net/eagle0/eagle/eagle_server_deploy.jar`
|
||||
2. **Shardok binary:** `bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server`
|
||||
|
||||
### Cross-Compilation for Linux
|
||||
|
||||
Current builds target macOS (self-hosted runner). For production Linux deployment:
|
||||
|
||||
**Option A: Build in Docker (Recommended)**
|
||||
```bash
|
||||
# Build Eagle (JVM - platform independent)
|
||||
bazel build //src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar
|
||||
|
||||
# Build Shardok in Linux container
|
||||
docker run --rm -v $(pwd):/workspace -w /workspace \
|
||||
ubuntu:22.04 \
|
||||
bash -c "apt-get update && apt-get install -y build-essential && bazel build -c opt //src/main/cpp/net/eagle0/shardok:shardok-server"
|
||||
```
|
||||
|
||||
**Option B: Use GitHub-hosted Linux runner**
|
||||
- Add `runs-on: ubuntu-latest` workflow
|
||||
- Builds directly on Linux
|
||||
- May need to cache Bazel to avoid long build times
|
||||
|
||||
**Option C: Cross-compile on Mac**
|
||||
- Configure Bazel for Linux cross-compilation
|
||||
- More complex setup but faster iteration
|
||||
|
||||
**Recommendation:** Option A (Docker build) for Shardok, since Eagle's JAR is platform-independent.
|
||||
|
||||
---
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
### GitHub Actions Workflows
|
||||
|
||||
**New workflow:** `.github/workflows/deploy_production.yml`
|
||||
|
||||
```yaml
|
||||
name: Deploy to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'src/main/scala/**'
|
||||
- 'src/main/cpp/**'
|
||||
- 'src/main/protobuf/**'
|
||||
- 'ci/*.Dockerfile'
|
||||
- 'docker-compose.prod.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: 'Deployment environment'
|
||||
required: true
|
||||
default: 'production'
|
||||
type: choice
|
||||
options:
|
||||
- production
|
||||
- staging
|
||||
|
||||
env:
|
||||
REGISTRY: registry.digitalocean.com
|
||||
EAGLE_IMAGE: eagle0/eagle-server
|
||||
SHARDOK_IMAGE: eagle0/shardok-server
|
||||
|
||||
jobs:
|
||||
build-eagle:
|
||||
runs-on: self-hosted
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Set version
|
||||
id: version
|
||||
run: echo "version=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build Eagle server JAR
|
||||
run: bazel build //src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar
|
||||
|
||||
- name: Copy artifacts
|
||||
run: |
|
||||
mkdir -p deploy
|
||||
cp bazel-bin/src/main/scala/net/eagle0/eagle/eagle_server_deploy.jar deploy/
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build -f ci/eagle_run.Dockerfile -t ${{ env.REGISTRY }}/${{ env.EAGLE_IMAGE }}:${{ steps.version.outputs.version }} .
|
||||
docker tag ${{ env.REGISTRY }}/${{ env.EAGLE_IMAGE }}:${{ steps.version.outputs.version }} ${{ env.REGISTRY }}/${{ env.EAGLE_IMAGE }}:latest
|
||||
|
||||
- name: Push to registry
|
||||
run: |
|
||||
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.DO_REGISTRY_TOKEN }} --password-stdin
|
||||
docker push ${{ env.REGISTRY }}/${{ env.EAGLE_IMAGE }}:${{ steps.version.outputs.version }}
|
||||
docker push ${{ env.REGISTRY }}/${{ env.EAGLE_IMAGE }}:latest
|
||||
|
||||
build-shardok:
|
||||
runs-on: ubuntu-latest
|
||||
needs: []
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Set version
|
||||
id: version
|
||||
run: echo "version=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Bazel
|
||||
uses: bazelbuild/setup-bazelisk@v2
|
||||
|
||||
- name: Cache Bazel
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cache/bazel
|
||||
key: bazel-linux-${{ hashFiles('MODULE.bazel', 'WORKSPACE') }}
|
||||
|
||||
- name: Build Shardok server
|
||||
run: bazel build -c opt //src/main/cpp/net/eagle0/shardok:shardok-server
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
mkdir -p bazel-bin/src/main/cpp/net/eagle0/shardok/
|
||||
cp bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server bazel-bin/src/main/cpp/net/eagle0/shardok/
|
||||
docker build -f ci/shardok_run.Dockerfile -t ${{ env.REGISTRY }}/${{ env.SHARDOK_IMAGE }}:${{ steps.version.outputs.version }} .
|
||||
docker tag ${{ env.REGISTRY }}/${{ env.SHARDOK_IMAGE }}:${{ steps.version.outputs.version }} ${{ env.REGISTRY }}/${{ env.SHARDOK_IMAGE }}:latest
|
||||
|
||||
- name: Push to registry
|
||||
run: |
|
||||
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ secrets.DO_REGISTRY_TOKEN }} --password-stdin
|
||||
docker push ${{ env.REGISTRY }}/${{ env.SHARDOK_IMAGE }}:${{ steps.version.outputs.version }}
|
||||
docker push ${{ env.REGISTRY }}/${{ env.SHARDOK_IMAGE }}:latest
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-eagle, build-shardok]
|
||||
environment: production
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy to DigitalOcean
|
||||
uses: appleboy/ssh-action@v1.0.0
|
||||
with:
|
||||
host: ${{ secrets.DO_DROPLET_IP }}
|
||||
username: deploy
|
||||
key: ${{ secrets.DO_SSH_KEY }}
|
||||
script: |
|
||||
cd /opt/eagle0
|
||||
|
||||
# Pull latest images
|
||||
docker-compose -f docker-compose.prod.yml pull
|
||||
|
||||
# Rolling restart (zero-downtime if load balancer configured)
|
||||
docker-compose -f docker-compose.prod.yml up -d --remove-orphans
|
||||
|
||||
# Wait for health checks
|
||||
sleep 30
|
||||
docker-compose -f docker-compose.prod.yml ps
|
||||
|
||||
# Cleanup old images
|
||||
docker image prune -f
|
||||
|
||||
- name: Verify deployment
|
||||
run: |
|
||||
# Health check endpoint
|
||||
curl -f https://eagle0.net/health || exit 1
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
run: |
|
||||
# Add Slack/Discord notification here
|
||||
echo "Deployment failed!"
|
||||
```
|
||||
|
||||
### Deployment Steps
|
||||
|
||||
1. **On push to main:**
|
||||
- Build Eagle JAR (self-hosted Mac runner - for consistency)
|
||||
- Build Shardok binary (GitHub-hosted Ubuntu runner)
|
||||
- Build Docker images
|
||||
- Push to DigitalOcean Container Registry
|
||||
|
||||
2. **Deploy to droplet:**
|
||||
- SSH to production server
|
||||
- Pull new images
|
||||
- `docker-compose up -d` (rolling update)
|
||||
- Verify health checks
|
||||
|
||||
3. **Rollback:**
|
||||
```bash
|
||||
# On server
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
docker-compose -f docker-compose.prod.yml pull eagle0/eagle-server:<previous-version>
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server Setup
|
||||
|
||||
### Initial Droplet Setup
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Run on fresh DigitalOcean droplet (Ubuntu 22.04)
|
||||
|
||||
# Update system
|
||||
apt-get update && apt-get upgrade -y
|
||||
|
||||
# Install Docker
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
systemctl enable docker
|
||||
systemctl start docker
|
||||
|
||||
# Install Docker Compose
|
||||
apt-get install -y docker-compose-plugin
|
||||
|
||||
# Create deploy user
|
||||
useradd -m -s /bin/bash -G docker deploy
|
||||
mkdir -p /home/deploy/.ssh
|
||||
# Add your SSH public key to /home/deploy/.ssh/authorized_keys
|
||||
|
||||
# Create app directory
|
||||
mkdir -p /opt/eagle0
|
||||
chown deploy:deploy /opt/eagle0
|
||||
|
||||
# Configure Docker to use DigitalOcean Container Registry
|
||||
docker login registry.digitalocean.com
|
||||
|
||||
# Create systemd service for auto-start
|
||||
cat > /etc/systemd/system/eagle0.service << 'EOF'
|
||||
[Unit]
|
||||
Description=Eagle0 Game Servers
|
||||
Requires=docker.service
|
||||
After=docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
WorkingDirectory=/opt/eagle0
|
||||
ExecStart=/usr/bin/docker compose -f docker-compose.prod.yml up -d
|
||||
ExecStop=/usr/bin/docker compose -f docker-compose.prod.yml down
|
||||
User=deploy
|
||||
Group=deploy
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl enable eagle0
|
||||
```
|
||||
|
||||
### Load Balancer Configuration
|
||||
|
||||
**DigitalOcean Load Balancer settings:**
|
||||
|
||||
- **Forwarding Rules:**
|
||||
- HTTPS 443 → HTTP 40032 (Eagle)
|
||||
- gRPC is HTTP/2, handled automatically
|
||||
|
||||
- **Health Checks:**
|
||||
- Protocol: HTTP
|
||||
- Port: 40032
|
||||
- Path: `/health` (need to implement)
|
||||
|
||||
- **SSL:**
|
||||
- Let's Encrypt certificate for `eagle0.net`
|
||||
|
||||
- **Settings:**
|
||||
- Sticky sessions: Disabled (gRPC streams handle this)
|
||||
- Proxy protocol: Disabled
|
||||
|
||||
### nginx on Droplet (Alternative to LB)
|
||||
|
||||
If using nginx instead of managed LB:
|
||||
|
||||
```nginx
|
||||
# /etc/nginx/sites-available/eagle0
|
||||
upstream eagle_backend {
|
||||
server 127.0.0.1:40032;
|
||||
keepalive 100;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name eagle0.net;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/eagle0.net/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/eagle0.net/privkey.pem;
|
||||
|
||||
# gRPC settings
|
||||
location /net.eagle0.eagle.api.Eagle {
|
||||
grpc_pass grpc://eagle_backend;
|
||||
grpc_read_timeout 1200s;
|
||||
grpc_send_timeout 1200s;
|
||||
grpc_socket_keepalive on;
|
||||
|
||||
# Rate limiting
|
||||
limit_req zone=eagle burst=50 nodelay;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
proxy_pass http://127.0.0.1:40032/health;
|
||||
}
|
||||
}
|
||||
|
||||
# Rate limit zone
|
||||
limit_req_zone $binary_remote_addr zone=eagle:10m rate=100r/s;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Secrets Management
|
||||
|
||||
**GitHub Secrets (for CI/CD):**
|
||||
- `DO_REGISTRY_TOKEN` - DigitalOcean Container Registry token
|
||||
- `DO_DROPLET_IP` - Production server IP
|
||||
- `DO_SSH_KEY` - SSH private key for deployment
|
||||
- `OPENAI_API_KEY` - For LLM integration (if used in production)
|
||||
- `DO_SPACES_KEY` - Already exists for S3
|
||||
|
||||
**On Server (environment variables):**
|
||||
```bash
|
||||
# /opt/eagle0/.env
|
||||
GPT_MODEL_NAME=gpt-4
|
||||
OPENAI_API_KEY=sk-...
|
||||
DO_SPACES_KEY=...
|
||||
DO_SPACES_SECRET=...
|
||||
JAVA_OPTS=-Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200
|
||||
```
|
||||
|
||||
### Configuration Files
|
||||
|
||||
**Production config** (`/opt/eagle0/config/eagle0.conf`):
|
||||
```
|
||||
monteCarloIterations = 150000
|
||||
monteCarloThreads = 4
|
||||
grpcAddress = 0.0.0.0:40042
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
### Logging
|
||||
|
||||
**Docker logging driver:** json-file with rotation
|
||||
- Logs stored in `/var/lib/docker/containers/<id>/`
|
||||
- Max 5 files of 100MB each
|
||||
|
||||
**Log aggregation options:**
|
||||
1. **DigitalOcean Logs** - $0 for basic, integrates with Droplets
|
||||
2. **Papertrail** - $7/mo for 1GB, good search
|
||||
3. **Self-hosted Loki** - Free, more complex
|
||||
|
||||
### Metrics
|
||||
|
||||
**Prometheus + Grafana** (optional, for advanced monitoring):
|
||||
```yaml
|
||||
# Add to docker-compose.prod.yml
|
||||
prometheus:
|
||||
image: prom/prometheus
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
ports:
|
||||
- "9090:9090"
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
|
||||
```
|
||||
|
||||
**Key metrics to track:**
|
||||
- Connection count
|
||||
- Request latency (p50, p95, p99)
|
||||
- Error rate
|
||||
- CPU/memory usage
|
||||
- Shardok AI search depth/time
|
||||
|
||||
### Alerting
|
||||
|
||||
**DigitalOcean Monitoring Alerts:**
|
||||
- CPU > 80% for 5 minutes
|
||||
- Memory > 90%
|
||||
- Disk > 85%
|
||||
- Droplet unreachable
|
||||
|
||||
**Uptime monitoring:**
|
||||
- Use UptimeRobot (free tier) or Better Uptime
|
||||
- Check `https://eagle0.net/health` every minute
|
||||
|
||||
---
|
||||
|
||||
## Cost Estimate
|
||||
|
||||
### Recommended Starting Configuration
|
||||
|
||||
| Component | Monthly Cost | Notes |
|
||||
|-----------|-------------|-------|
|
||||
| Droplet (s-2vcpu-4gb) | $24 | Eagle always-on, Shardok on-demand |
|
||||
| Spaces | ~$5 | Already paying |
|
||||
| Container Registry | $5 | For Docker images |
|
||||
| DNS | $0 | Included |
|
||||
| Bandwidth | ~$0 | 1TB free, then $0.01/GB |
|
||||
| **Total** | **~$34/mo** | |
|
||||
|
||||
### Optional Add-ons
|
||||
|
||||
| Component | Monthly Cost | Notes |
|
||||
|-----------|-------------|-------|
|
||||
| Load Balancer | +$12 | Only if need HA/failover |
|
||||
| Monitoring (Papertrail) | +$7 | Better log search |
|
||||
|
||||
### Scaling Costs
|
||||
|
||||
| Scenario | Droplet | Monthly Cost |
|
||||
|----------|---------|--------------|
|
||||
| Current (few players) | s-2vcpu-4gb | $24 |
|
||||
| Growing usage | s-4vcpu-8gb | $48 |
|
||||
| CPU-intensive battles | c-4 (dedicated) | $84 |
|
||||
| High concurrency | c-8 (dedicated) | $168 |
|
||||
| Separate Shardok (on-demand) | s-1vcpu-2gb + c-4 hourly | $12 + usage |
|
||||
|
||||
---
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Infrastructure Setup (Day 1)
|
||||
|
||||
1. Create DigitalOcean resources:
|
||||
- Droplet in SFO3 region
|
||||
- Container Registry
|
||||
- (Optional) Load Balancer
|
||||
|
||||
2. Configure DNS:
|
||||
- Point `eagle0.net` to new infrastructure
|
||||
- Keep `qa.eagle0.net` pointing to home IP
|
||||
|
||||
3. Set up server:
|
||||
- Run initial setup script
|
||||
- Configure Docker and docker-compose
|
||||
- Test SSH access
|
||||
|
||||
### Phase 2: Build Pipeline (Day 2)
|
||||
|
||||
1. Create Dockerfiles:
|
||||
- Enhance `ci/eagle_run.Dockerfile`
|
||||
- Create `ci/shardok_run.Dockerfile`
|
||||
|
||||
2. Create `docker-compose.prod.yml`
|
||||
|
||||
3. Set up GitHub Actions:
|
||||
- Add deployment workflow
|
||||
- Configure secrets
|
||||
- Test build pipeline
|
||||
|
||||
### Phase 3: Deployment (Day 3)
|
||||
|
||||
1. Deploy to production:
|
||||
- Push first images
|
||||
- Start containers
|
||||
- Verify health checks
|
||||
|
||||
2. Configure TLS:
|
||||
- Set up Let's Encrypt
|
||||
- Update nginx/LB configuration
|
||||
|
||||
3. Update DNS:
|
||||
- Switch `eagle0.net` to production
|
||||
- Verify client can connect
|
||||
|
||||
### Phase 4: Validation (Day 4)
|
||||
|
||||
1. Test gameplay:
|
||||
- Connect from Unity client
|
||||
- Play through Eagle gameplay
|
||||
- Test Shardok combat
|
||||
|
||||
2. Monitor:
|
||||
- Check logs for errors
|
||||
- Verify resource usage
|
||||
- Test reconnection behavior
|
||||
|
||||
3. Document:
|
||||
- Update runbooks
|
||||
- Document rollback procedures
|
||||
|
||||
### Phase 5: QA Environment (Day 5)
|
||||
|
||||
1. Configure `qa.eagle0.net`:
|
||||
- Keep pointing to home Mac
|
||||
- Ensure nginx routes correctly
|
||||
|
||||
2. Test environment switching:
|
||||
- Connect to production
|
||||
- Switch to QA
|
||||
- Verify different game states
|
||||
|
||||
---
|
||||
|
||||
## Rollback Procedure
|
||||
|
||||
### Quick Rollback (< 5 minutes)
|
||||
|
||||
```bash
|
||||
# SSH to production server
|
||||
ssh deploy@<droplet-ip>
|
||||
|
||||
# Roll back to previous version
|
||||
cd /opt/eagle0
|
||||
docker-compose -f docker-compose.prod.yml down
|
||||
docker pull registry.digitalocean.com/eagle0/eagle-server:<previous-tag>
|
||||
docker pull registry.digitalocean.com/eagle0/shardok-server:<previous-tag>
|
||||
VERSION=<previous-tag> docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### Full Rollback to Home Mac
|
||||
|
||||
1. Update DNS: Point `eagle0.net` back to home IP
|
||||
2. Ensure home Mac servers are running
|
||||
3. Wait for DNS propagation (5-30 minutes)
|
||||
|
||||
---
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] SSH key authentication only (disable password)
|
||||
- [ ] Firewall configured (only 443, 22 from trusted IPs)
|
||||
- [ ] TLS 1.3 enforced
|
||||
- [ ] Secrets in environment variables, not in code
|
||||
- [ ] Container runs as non-root user
|
||||
- [ ] Rate limiting on gRPC endpoints
|
||||
- [ ] Regular security updates (`unattended-upgrades`)
|
||||
- [ ] Remove Shardok internal interface from public nginx (per CONNECTION_ARCHITECTURE.md)
|
||||
|
||||
---
|
||||
|
||||
## Resolved Questions
|
||||
|
||||
1. **Game state migration:** No migration needed. Saves are currently local only. The codebase has a `Persister` pattern (with existing AWS support) that could be adapted to save to DO Spaces in the future.
|
||||
|
||||
2. **LLM API keys:** Yes, OpenAI API keys are needed on the Eagle server. Add `OPENAI_API_KEY` to the `.env` file on the production droplet.
|
||||
|
||||
3. **Multiple regions:** Not needed for now. Single SFO3 region is sufficient.
|
||||
|
||||
## Remaining Open Questions
|
||||
|
||||
1. **Backup strategy:** Should we add automated backups for local persistence, or migrate to DO Spaces persistence first?
|
||||
|
||||
2. **Autoscaling:** Is traffic predictable enough to use fixed instance, or need autoscaling later?
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Phase 1: Infrastructure
|
||||
1. **Approve this plan** - Review and discuss any changes
|
||||
2. **Create DigitalOcean resources** - Droplet (s-2vcpu-4gb), Container Registry
|
||||
3. **Set up droplet** - Docker, deploy user, firewall, nginx with TLS
|
||||
|
||||
### Phase 2: Containerization
|
||||
4. **Implement Dockerfiles** - Eagle and Shardok containers
|
||||
5. **Implement Shardok lifecycle management** - Eagle starts/stops Shardok on-demand
|
||||
- Add `ShardokProcessManager` to Eagle server
|
||||
- Start Shardok when battle requested
|
||||
- Stop after idle timeout (5 min)
|
||||
- Health check before routing traffic
|
||||
|
||||
### Phase 3: CI/CD
|
||||
6. **Set up GitHub Actions** - Build and push Docker images
|
||||
7. **Add deployment workflow** - SSH deploy to droplet
|
||||
|
||||
### Phase 4: Migration
|
||||
8. **Deploy to production** - Push images, start services
|
||||
9. **Update DNS** - Point eagle0.net to droplet
|
||||
10. **Validate** - Test gameplay, monitor logs
|
||||
11. **Configure QA** - Ensure qa.eagle0.net still works (home Mac)
|
||||
@@ -1,205 +0,0 @@
|
||||
# Scala 3 Modernization Guide
|
||||
|
||||
## Overview
|
||||
This document outlines opportunities to modernize the Eagle0 codebase to use Scala 3 best practices and features. The migration to Scala 3 is complete, but the code still uses many Scala 2 patterns that can be improved.
|
||||
|
||||
## Modernization Opportunities
|
||||
|
||||
### 1. **Convert Sealed Traits to Enums** 🎯 HIGH IMPACT
|
||||
**Benefits**: Better performance, more concise syntax, improved exhaustiveness checking
|
||||
|
||||
**Current pattern** (`ExternalTextGenerationCaller.scala:23-31`):
|
||||
```scala
|
||||
sealed trait ExternalTextGenerationError extends Error {
|
||||
def message: String
|
||||
}
|
||||
case class ExternalTextGenerationRateLimitError(code: Int, message: String)
|
||||
extends ExternalTextGenerationError
|
||||
case class ExternalTextGenerationHttpError(code: Int, message: String)
|
||||
extends ExternalTextGenerationError
|
||||
case class ExternalTextGenerationTimeoutError(message: String)
|
||||
extends ExternalTextGenerationError
|
||||
```
|
||||
|
||||
**Scala 3 improvement**:
|
||||
```scala
|
||||
enum ExternalTextGenerationError extends Error:
|
||||
case RateLimit(code: Int, message: String)
|
||||
case Http(code: Int, message: String)
|
||||
case Timeout(message: String)
|
||||
|
||||
def message: String = this match
|
||||
case RateLimit(_, msg) => msg
|
||||
case Http(_, msg) => msg
|
||||
case Timeout(msg) => msg
|
||||
```
|
||||
|
||||
**Files to check**:
|
||||
- `/src/main/scala/net/eagle0/common/llm_integration/ExternalTextGenerationCaller.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request/GeneratedTextRequestT.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/model/state/quest/concrete/QuestC.scala`
|
||||
|
||||
### 2. **Convert Implicit Classes to Extension Methods** 🎯 HIGH IMPACT
|
||||
**Benefits**: Modern syntax, better IDE support, cleaner imports
|
||||
|
||||
**Current pattern** (`MoreSeq.scala:23-26`):
|
||||
```scala
|
||||
implicit def SeqCollect[A, Repr[_]](coll: Repr[A])(implicit
|
||||
itr: IsIterable[Repr[A]]
|
||||
): SeqCollect[A, Repr, itr.type] =
|
||||
new SeqCollect[A, Repr, itr.type](coll, itr)
|
||||
```
|
||||
|
||||
**Scala 3 improvement**:
|
||||
```scala
|
||||
extension [A, Repr[_]](coll: Repr[A])(using itr: IsIterable[Repr[A]])
|
||||
def flatCollect[B](pf: PartialFunction[itr.A, Option[B]])(using Factory[B, Repr[B]]): Repr[B] =
|
||||
Factory[B, Repr[B]].fromSpecific(itr(coll).collect(pf).flatten)
|
||||
|
||||
def flatCollectFirst[B](pf: PartialFunction[itr.A, Option[B]]): Option[B] =
|
||||
itr(coll).collect(pf).flatten.headOption
|
||||
```
|
||||
|
||||
**Files to check**:
|
||||
- `/src/main/scala/net/eagle0/common/MoreSeq.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/CommandChooser.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/service/new_game_creation/NewGameCreation.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultProtoApplierImpl.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/service/new_game_creation/StartGameActionResultUtils.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/model/state/date/Date.scala`
|
||||
|
||||
### 3. **Convert Implicit Parameters to Using Clauses** 🎯 MEDIUM IMPACT
|
||||
**Benefits**: Cleaner syntax, better tooling support, clearer intent
|
||||
|
||||
**Current pattern**:
|
||||
```scala
|
||||
def method[T](value: T)(implicit ec: ExecutionContext): Future[T]
|
||||
def process[A](items: Seq[A])(implicit ord: Ordering[A]): Seq[A]
|
||||
```
|
||||
|
||||
**Scala 3 improvement**:
|
||||
```scala
|
||||
def method[T](value: T)(using ExecutionContext): Future[T]
|
||||
def process[A](items: Seq[A])(using Ordering[A]): Seq[A]
|
||||
```
|
||||
|
||||
**Files to check**:
|
||||
- `/src/main/scala/net/eagle0/common/MoreSeq.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/library/util/hero_name_fetcher/HeroNameFetcher.scala`
|
||||
- `/src/main/scala/net/eagle0/eagle/library/util/ShardokMapInfo.scala`
|
||||
- `/src/main/scala/net/eagle0/common/llm_integration/OpenAIChatCompletionsServiceImpl.scala`
|
||||
- `/src/main/scala/net/eagle0/common/llm_integration/ClaudeServiceImpl.scala`
|
||||
|
||||
### 4. **Opaque Types for Type Safety** 🎯 MEDIUM IMPACT
|
||||
**Benefits**: Zero runtime cost, compile-time type safety, prevents mixing up similar types
|
||||
|
||||
**Pattern to look for**: Type aliases that represent distinct concepts
|
||||
```scala
|
||||
// Instead of: type UserId = String, type GameId = String
|
||||
opaque type UserId = String
|
||||
object UserId:
|
||||
def apply(s: String): UserId = s
|
||||
extension (id: UserId)
|
||||
def value: String = id
|
||||
def isValid: Boolean = id.nonEmpty && id.length > 3
|
||||
|
||||
opaque type GameId = Long
|
||||
object GameId:
|
||||
def apply(l: Long): GameId = l
|
||||
extension (id: GameId) def value: Long = id
|
||||
```
|
||||
|
||||
**Candidates**: Look for simple type aliases and ID types throughout the codebase.
|
||||
|
||||
### 5. **Inline Methods for Performance** 🎯 LOW IMPACT
|
||||
**Benefits**: Compile-time optimization, better performance for hot paths
|
||||
|
||||
**Pattern**: Mark small, frequently-called methods as `inline`
|
||||
```scala
|
||||
inline def isValidId(id: String): Boolean =
|
||||
id.nonEmpty && id.length > 3
|
||||
|
||||
inline def calculateScore(base: Int, multiplier: Double): Double =
|
||||
base * multiplier
|
||||
```
|
||||
|
||||
**Candidates**: Small utility methods in performance-critical paths (AI calculations, game state updates).
|
||||
|
||||
### 6. **Union Types Instead of Complex Hierarchies** 🎯 LOW IMPACT
|
||||
**Benefits**: Simpler type definitions for either/or scenarios
|
||||
|
||||
**Pattern**: Simple sealed traits with only case classes
|
||||
```scala
|
||||
// Instead of:
|
||||
sealed trait Result
|
||||
case class Success(value: String) extends Result
|
||||
case class Error(message: String) extends Result
|
||||
|
||||
// Consider:
|
||||
type Result = Success | Error
|
||||
case class Success(value: String)
|
||||
case class Error(message: String)
|
||||
```
|
||||
|
||||
### 7. **Context Functions for Cleaner APIs** 🎯 LOW IMPACT
|
||||
**Benefits**: Cleaner API design, implicit context passing
|
||||
|
||||
**Pattern**: Replace implicit function parameters
|
||||
```scala
|
||||
// Old
|
||||
type Handler = GameState => Unit
|
||||
def withGameState(gs: GameState)(handler: Handler): Unit = handler(gs)
|
||||
|
||||
// New
|
||||
type Handler = GameState ?=> Unit
|
||||
def withGameState(gs: GameState)(handler: Handler): Unit =
|
||||
given GameState = gs
|
||||
handler
|
||||
```
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
### Phase 1: Quick Wins (High Impact, Low Risk)
|
||||
1. **Convert Extension Methods** in `MoreSeq.scala` - immediate readability improvement
|
||||
2. **Update Using Clauses** - simple find/replace operation
|
||||
3. **Convert Simple Sealed Traits to Enums** - start with error types
|
||||
|
||||
### Phase 2: Type Safety Improvements
|
||||
4. **Add Opaque Types** for IDs and measurements - improves type safety
|
||||
5. **Inline Performance-Critical Methods** - measure before/after impact
|
||||
|
||||
### Phase 3: Advanced Features (Lower Priority)
|
||||
6. **Union Types** where appropriate - only for simple either/or cases
|
||||
7. **Context Functions** for complex API improvements
|
||||
|
||||
## Implementation Guidelines
|
||||
|
||||
### Style Consistency
|
||||
- **Keep curly braces**: Continue using Scala 2 style `{}` instead of indentation-based syntax
|
||||
- **Gradual adoption**: Modernize files as they're touched for other reasons
|
||||
- **Test thoroughly**: Each modernization should include verification that behavior is unchanged
|
||||
|
||||
### Performance Considerations
|
||||
- **Measure enum performance**: Verify that enum conversion actually improves performance in hot paths
|
||||
- **Benchmark inline methods**: Use profiling to confirm performance gains
|
||||
- **Consider compilation time**: Some features may increase compile time
|
||||
|
||||
### Migration Strategy
|
||||
- **File-by-file approach**: Complete modernization of one file at a time
|
||||
- **Separate PRs**: Each modernization type should be its own PR for easier review
|
||||
- **Documentation**: Update this document as patterns are modernized
|
||||
|
||||
## Success Criteria
|
||||
- [ ] All extension methods converted from implicit classes
|
||||
- [ ] All implicit parameters converted to using clauses
|
||||
- [ ] Key sealed traits converted to enums where appropriate
|
||||
- [ ] Opaque types introduced for important ID types
|
||||
- [ ] Performance-critical methods marked as inline (with benchmarks)
|
||||
- [ ] No regression in functionality or performance
|
||||
- [ ] Code remains readable and maintainable
|
||||
|
||||
## Notes
|
||||
- Focus on high-impact, low-risk improvements first
|
||||
- Each change should be driven by clear benefits (performance, readability, type safety)
|
||||
- Maintain backward compatibility where possible
|
||||
- Document any breaking changes clearly
|
||||
@@ -1,305 +0,0 @@
|
||||
# Actions and Commands Model Usage Analysis
|
||||
|
||||
This document analyzes all actions and commands in `src/main/scala/net/eagle0/eagle/library/actions/impl` to determine which use Scala models vs protobuf models, based on BUILD.bazel dependencies.
|
||||
|
||||
**Legend:**
|
||||
- ✅ **Scala Models Only** - Uses only `//src/main/scala/net/eagle0/eagle/model` dependencies
|
||||
- ❌ **Uses Protobuf** - Has dependencies on `//src/main/protobuf` targets
|
||||
- 🔄 **Partial Conversion** - Conversion attempted but blocked by dependencies
|
||||
|
||||
## Summary
|
||||
|
||||
Based on BUILD.bazel dependency analysis (2025-09-16, updated 2025-09-17):
|
||||
- **Total Commands Analyzed:** 41
|
||||
- **Commands Fully Migrated (No Protobuf):** 41 (100%) ✅
|
||||
- **Commands Still Using Protobuf:** 0 (0%) ✅
|
||||
- **Total Actions Analyzed:** 48
|
||||
- **Actions Fully Migrated (No Protobuf):** 5 (10.4%)
|
||||
- **Actions Partially Migrated:** 19 (39.6%)
|
||||
- **Actions Still Using Protobuf:** 24 (50%)
|
||||
- **Base Classes:** 8 protoless variants available, 6 still use protobuf
|
||||
- **Shared Components:** `ResolvedEagleUnit` migrated to use `Option[BattalionT]` for proper null handling
|
||||
|
||||
## Conversion Insights
|
||||
|
||||
Based on conversion attempt of `ResolveTruceOfferCommand` (see [PR #4379](https://github.com/nolen777/eagle0/pull/4379)):
|
||||
|
||||
### Key Challenges Discovered
|
||||
|
||||
1. **LLM Integration Dependencies**: Commands that use `DiplomacyResolutionLlmRequestGenerator` face challenges because the LLM system still expects protobuf enum types, not Scala model enums.
|
||||
|
||||
2. **Inconsistent Package Naming**: Some files have inconsistent package declarations vs BUILD file locations (e.g., `generated_text_request_generators` in package vs `llm_request_generators` in BUILD).
|
||||
|
||||
3. **Model Constructor Differences**: Scala model constructors (e.g., `TruceOffer`) have different required parameters than their protobuf counterparts, requiring more complex data mapping.
|
||||
|
||||
4. **Type System Complexity**: Union types and type constraints become more complex when mixing protobuf and Scala model types during transition.
|
||||
|
||||
5. **Cascading Dependency Issues**: Converting to `ActionResultC` requires extensive trait dependencies (`ChangedBattalionT`, `ChangedHeroT`, `GeneratedTextRequestT`, etc.) that create complex BUILD dependency graphs, unlike simple protobuf `ActionResult`.
|
||||
|
||||
6. **BUILD Complexity**: Each Scala model conversion requires significantly more BUILD dependencies than protobuf equivalents, making incremental conversion difficult.
|
||||
|
||||
7. **Build Verification Critical**: Any conversion must maintain working build state - even simple commands like `DefendCommand` can break main server build due to dependency cascades.
|
||||
### Successful Conversion Elements
|
||||
|
||||
- ✅ Base class conversion (`SimpleAction` → `ProtolessSimpleAction`)
|
||||
- ✅ Import updates for most Scala model types
|
||||
- ✅ BUILD.bazel dependency updates for core action result types
|
||||
- ✅ Basic type conversions for simple cases
|
||||
|
||||
### Recommended Conversion Strategy
|
||||
|
||||
1. **Architecture-First Approach**: Convert base infrastructure (LLM generators, action result builders) before individual commands
|
||||
2. **Wrapper Pattern**: Use existing `Protoless*ActionWrapper` classes as templates for gradual transition
|
||||
3. **Dependency Analysis**: Map full dependency trees before attempting conversions to avoid cascading build failures
|
||||
4. **Batch Conversions**: Convert related commands together to minimize dependency conflicts
|
||||
5. **Build Verification**: **ALWAYS** verify `//src/main/scala/net/eagle0/eagle:eagle_server` and test suite build before creating PRs
|
||||
|
||||
### Conversion Requirements
|
||||
|
||||
**Before creating any PR:**
|
||||
- ✅ `bazel build //src/main/scala/net/eagle0/eagle:eagle_server` succeeds
|
||||
- ✅ `bazel test //src/test/scala/... --keep_going` passes (or doesn't introduce new failures)
|
||||
- ✅ All BUILD dependencies are correctly specified
|
||||
- ✅ Scalafmt and other linters pass
|
||||
|
||||
---
|
||||
|
||||
## Common Base Classes
|
||||
|
||||
| File | Type | Model Usage | Notes |
|
||||
|------|------|-------------|-------|
|
||||
| Action.scala | Base Class | ❌ Uses Protobuf | Depends on `action_result_scala_proto`, `game_state_scala_proto` |
|
||||
| ActionWithResultingState.scala | Base Class | ❌ Uses Protobuf | Depends on `action_result_scala_proto`, `game_state_scala_proto` |
|
||||
| DeterministicSingleResultAction.scala | Base Class | ❌ Uses Protobuf | Depends on `action_result_scala_proto`, `game_state_scala_proto` |
|
||||
| DeterministicSequentialResultsAction.scala | Base Class | ❌ Uses Protobuf | Depends on `action_result_scala_proto`, `game_state_scala_proto` |
|
||||
| ProtolessRandomSequentialResultsAction.scala | Base Class | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model/action_result` |
|
||||
| ProtolessRandomSimpleAction.scala | Base Class | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model/action_result` |
|
||||
| ProtolessSequentialResultsAction.scala | Base Class | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model/action_result` |
|
||||
| ProtolessSimpleAction.scala | Base Class | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model/action_result` |
|
||||
| RandomSequentialResultsAction.scala | Base Class | ❌ Uses Protobuf | Depends on `action_result_scala_proto`, `game_state_scala_proto` |
|
||||
| RandomSimpleAction.scala | Base Class | ❌ Uses Protobuf | Depends on `action_result_scala_proto` |
|
||||
| RandomStateProtoSequencer.scala | Sequencer | ❌ Uses Protobuf | Bridge class, depends on both protobuf and Scala models |
|
||||
| RandomStateTSequencer.scala | Sequencer | ❌ Uses Protobuf | Bridge class, depends on both protobuf and Scala models |
|
||||
| SimpleAction.scala | Base Class | ❌ Uses Protobuf | Depends on `action_result_scala_proto` |
|
||||
| VigorXPApplier.scala | Utility | ❌ Uses Protobuf | Depends on `action_result_scala_proto` |
|
||||
|
||||
---
|
||||
|
||||
## Actions
|
||||
|
||||
### ✅ Fully Migrated Actions (No Protobuf Dependencies)
|
||||
|
||||
These actions have been successfully migrated to use Scala models only:
|
||||
|
||||
| File | Base Class | Notes |
|
||||
|------|------------|-------|
|
||||
| HeroBackstoryUpdateAction.scala | ProtolessSequentialResultsAction | Processes hero backstory updates with LLM integration |
|
||||
| ProvinceConqueredAction.scala | ProtolessSimpleAction | Uses component-based design (gameId, currentRoundId, currentDate, Scala models) |
|
||||
| ProvinceHeldAction.scala | ProtolessSimpleAction | Uses specific components (gameId, currentRoundId, defendingProvince, etc.) instead of full GameState |
|
||||
| UnaffiliatedHeroAppearedAction.scala | ProtolessSimpleAction | Handles unaffiliated hero appearance with name generation |
|
||||
| WithdrawnArmiesReturnHomeAction.scala | ProtolessSequentialResultsAction | Manages army withdrawal and return mechanics |
|
||||
|
||||
### 🔄 Actions Partially Migrated (Using Protoless Base Classes)
|
||||
|
||||
These actions use protoless base classes but still have some protobuf dependencies:
|
||||
|
||||
| File | Model Usage | Notes |
|
||||
|------|-------------|-------|
|
||||
| CheckForFactionChangesAction.scala | ProtolessSequentialResultsAction | Still has some protobuf dependencies |
|
||||
| CheckForFailedQuestsAction.scala | ProtolessSequentialResultsAction | Depends on `unaffiliated_hero_quest_scala_proto` |
|
||||
| CheckForFulfilledQuestsAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
|
||||
| EndAttackDecisionPhaseAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
|
||||
| EndBattleAftermathPhaseAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
|
||||
| EndFreeForAllDecisionPhaseAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
|
||||
| EndPlayerCommandsPhaseAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
|
||||
| EndUnaffiliatedHeroActionsPhaseAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
|
||||
| EndVassalCommandsPhaseAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
|
||||
| FreeForAllDrawAction.scala | ProtolessSimpleAction | Depends on multiple protobuf targets |
|
||||
| FriendlyMoveAction.scala | ProtolessSimpleAction | Depends on multiple protobuf targets |
|
||||
| PerformUncontestedConquestAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
|
||||
| ProvinceConqueredAction.scala | ProtolessSimpleAction | **CONVERTED** - Uses specific components (gameId, currentRoundId, currentDate, Scala models) |
|
||||
| SafePassageArmiesProceedAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
|
||||
| ShipmentArrivedAction.scala | ProtolessSimpleAction | Depends on multiple protobuf targets |
|
||||
| TruceTurnBackPhaseAction.scala | ProtolessSequentialResultsAction | Depends on multiple protobuf targets |
|
||||
| UnaffiliatedHeroRejoinedAction.scala | ProtolessSimpleAction | Depends on multiple protobuf targets |
|
||||
| WonFreeForAllAction.scala | ProtolessSimpleAction | Depends on multiple protobuf targets |
|
||||
|
||||
### ❌ Actions Still Using Protobuf (Not Yet Using Protoless Base Classes)
|
||||
|
||||
| File | Notes |
|
||||
|------|-------|
|
||||
| ChronicleEventGenerator.scala | Depends on multiple protobuf targets |
|
||||
| EndBattleRequestPhaseAction.scala | Depends on `diplomacy_offer_status_scala_proto` |
|
||||
| EndBattleResolutionPhaseAction.scala | Depends on multiple protobuf targets |
|
||||
| EndDefenseDecisionPhaseAction.scala | Depends on multiple protobuf targets |
|
||||
| EndDiplomacyResolutionPhaseAction.scala | Depends on multiple protobuf targets |
|
||||
| EndFreeForAllBattleRequestPhaseAction.scala | Depends on multiple protobuf targets |
|
||||
| EndFreeForAllBattleResolutionPhaseAction.scala | Depends on multiple protobuf targets |
|
||||
| EndHandleRiotsPhaseAction.scala | Depends on multiple protobuf targets |
|
||||
| EndPleaseRecruitMePhaseAction.scala | Depends on multiple protobuf targets |
|
||||
| EndProvinceMoveResolutionPhaseAction.scala | Depends on multiple protobuf targets |
|
||||
| NewRoundAction.scala | Depends on multiple protobuf targets |
|
||||
| NewYearAction.scala | Depends on multiple protobuf targets |
|
||||
| PerformFoodConsumptionPhaseAction.scala | Depends on multiple protobuf targets |
|
||||
| PerformForcedTurnBackAction.scala | Depends on multiple protobuf targets |
|
||||
| PerformHeroDeparturesAction.scala | Depends on multiple protobuf targets |
|
||||
| PerformHostileArmySetupAction.scala | Depends on multiple protobuf targets |
|
||||
| PerformProvinceEventsAction.scala | Depends on `province_event_scala_proto` |
|
||||
| PerformProvinceMoveResolutionAction.scala | Depends on multiple protobuf targets |
|
||||
| PerformReconResolutionAction.scala | Depends on multiple protobuf targets |
|
||||
| PerformUnaffiliatedHeroesAction.scala | Depends on `unaffiliated_hero_quest_scala_proto` |
|
||||
| PerformVassalCommandsPhaseAction.scala | Depends on multiple protobuf targets |
|
||||
| PerformVassalDefenseDecisionsAction.scala | Depends on multiple protobuf targets |
|
||||
| PrisonerEscapeAction.scala | Depends on `game_state_scala_proto` |
|
||||
| PrisonerExchangeAction.scala | Depends on multiple protobuf targets |
|
||||
| RequestBattlesAction.scala | Depends on multiple protobuf targets |
|
||||
| RequestFreeForAllBattlesAction.scala | Depends on multiple protobuf targets |
|
||||
| ResolveBattleAction.scala | Depends on `shardok_internal_interface_scala_grpc` |
|
||||
| UnaffiliatedHeroMovedAction.scala | Depends on multiple protobuf targets |
|
||||
| UnaffiliatedHeroesChangedAction.scala | Depends on multiple protobuf targets |
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
✅ **ALL COMMANDS FULLY MIGRATED** (100% - 41/41 commands)
|
||||
|
||||
All 41 commands in the codebase have been successfully migrated to use Scala models only, with no protobuf dependencies. This includes:
|
||||
|
||||
- **Simple Actions**: Use `ProtolessSimpleAction` base class
|
||||
- **Random Actions**: Use `ProtolessRandomSimpleAction` base class
|
||||
- **Complex Domain Models**: Successfully integrated with LLM systems, diplomacy, quest fulfillment, and state management
|
||||
- **Complete Type Safety**: All commands now use type-safe Scala domain models
|
||||
|
||||
**Key Migration Achievements:**
|
||||
- ✅ All military commands (ArmTroops, Train, Organize, etc.)
|
||||
- ✅ All diplomacy commands (Resolve Alliance/Truce/Ransom offers, etc.)
|
||||
- ✅ All LLM-integrated commands (backstory generation, diplomacy resolution)
|
||||
- ✅ All quest and event commands
|
||||
- ✅ Final remaining command (FreeForAllDecisionCommand) migrated
|
||||
|
||||
---
|
||||
|
||||
## Diplomacy Helpers
|
||||
|
||||
All diplomacy helpers use **Scala models only**:
|
||||
|
||||
| File | Model Usage | Notes |
|
||||
|------|-------------|-------|
|
||||
| AllianceResolutionHelpers.scala | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model` only |
|
||||
| BreakAllianceResolutionHelpers.scala | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model` only |
|
||||
| InvitationResolutionHelpers.scala | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model` only |
|
||||
| RansomResolutionHelpers.scala | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model` only |
|
||||
| TruceResolutionHelpers.scala | ✅ Scala Models Only | Uses `//src/main/scala/net/eagle0/eagle/model` only |
|
||||
|
||||
---
|
||||
|
||||
## Migration Priority Analysis
|
||||
|
||||
Based on the BUILD.bazel dependency analysis, here are the key findings and recommendations:
|
||||
|
||||
### 🎯 High Impact Migration Targets
|
||||
|
||||
**Core Dependencies Blocking Multiple Commands:**
|
||||
|
||||
1. **`action_result_scala_proto`** - Used by 12+ commands
|
||||
- Blocks: `DefendCommand`, `FreeForAllDecisionCommand`, diplomacy resolvers
|
||||
- Impact: Would unlock many command migrations
|
||||
|
||||
2. **`available_command_scala_proto` / `selected_command_scala_proto`** - Used by 10+ commands
|
||||
- Blocks: All UI-interactive commands
|
||||
- Impact: Would enable client-server interaction model migration
|
||||
|
||||
3. **`game_state_scala_proto`** - Used by 8+ commands
|
||||
- Blocks: Complex state-dependent commands
|
||||
- Impact: Core state representation migration
|
||||
|
||||
### 📊 Migration Tiers by Complexity
|
||||
|
||||
**Tier 1 - Quick Wins (2 commands):**
|
||||
- `ArmTroopsCommand` - Only `battalion_type` dependency
|
||||
- `TrainCommand` - Only `battalion_type` dependency
|
||||
- **Effort:** Low, **Impact:** Demonstrates battalion model usage
|
||||
|
||||
**Tier 2 - API Layer (5 commands):**
|
||||
- Commands blocked by `available_command`/`selected_command`
|
||||
- **Effort:** Medium, **Impact:** High (enables UI interaction models)
|
||||
|
||||
**Tier 3 - Diplomacy Suite (6 commands):**
|
||||
- All `Resolve*Command` diplomacy commands
|
||||
- **Effort:** High, **Impact:** High (complete diplomacy model migration)
|
||||
- **Strategy:** Migrate as a group after diplomacy models are ready
|
||||
|
||||
### 🏆 Success Metrics
|
||||
|
||||
**Current Status:**
|
||||
- ✅ **100% of commands fully migrated** (41/41) 🎉
|
||||
- ✅ **All diplomacy helpers use Scala models**
|
||||
- ✅ **All protoless base classes available**
|
||||
- ✅ **ALL command migration completed**
|
||||
|
||||
**Completed Milestones:**
|
||||
- ✅ **70% target:** Migrate Tier 1 + some Tier 2 commands **COMPLETED**
|
||||
- ✅ **80% target:** Continue with remaining non-diplomacy commands **COMPLETED**
|
||||
- ✅ **85% target:** Complete API layer migration **COMPLETED**
|
||||
- ✅ **95% target:** Complete diplomacy migration **COMPLETED**
|
||||
- ✅ **100% target:** Migrate final remaining command (FreeForAllDecisionCommand) **COMPLETED**
|
||||
|
||||
### 🎯 Action Migration Progress
|
||||
|
||||
**Migration Statistics:**
|
||||
- 5/48 Actions fully migrated (10.4%)
|
||||
- 20/48 Actions using protoless base classes but with protobuf dependencies (41.7%)
|
||||
- 24/48 Actions still fully on protobuf (50%)
|
||||
|
||||
**Successfully Migrated Actions:**
|
||||
1. **HeroBackstoryUpdateAction** - LLM integration for hero backstories
|
||||
2. **ProvinceConqueredAction** - Component-based design with prisoner handling and province conquest
|
||||
3. **ProvinceHeldAction** - Component-based design pattern (gameId, currentRoundId, specific models)
|
||||
4. **UnaffiliatedHeroAppearedAction** - Hero appearance with name generation
|
||||
5. **WithdrawnArmiesReturnHomeAction** - Army withdrawal mechanics
|
||||
|
||||
**Recent Migration Updates (2025-09-17):**
|
||||
- **ResolvedEagleUnit** - Changed `battalion: BattalionT` to `battalion: Option[BattalionT]`
|
||||
- Properly handles units without battalions (battalion ID -1)
|
||||
- Updated `ShardokInterfaceGrpcClient` to check for `defaultBattalionId` and use `None`
|
||||
- Updated `ResolveBattleAction`, `ProvinceConqueredAction`, `RequestBattlesAction`
|
||||
- All tests updated to handle optional battalions
|
||||
|
||||
**Key Migration Patterns:**
|
||||
- ✅ Use specific components instead of full GameState (see ProvinceHeldAction, ProvinceConqueredAction)
|
||||
- ✅ Convert protobuf models to Scala models at Action boundaries
|
||||
- ✅ Update BUILD.bazel to remove protobuf dependencies
|
||||
- ✅ Update all call sites and tests
|
||||
- ✅ Use `Option[T]` for optional fields instead of special sentinel values (e.g., battalion ID -1)
|
||||
|
||||
**Next Migration Candidates (Simple Actions with Protoless Base):**
|
||||
1. **FreeForAllDrawAction** - Already uses ProtolessSimpleAction
|
||||
2. **FriendlyMoveAction** - Already uses ProtolessSimpleAction
|
||||
3. **ShipmentArrivedAction** - Already uses ProtolessSimpleAction
|
||||
4. **WonFreeForAllAction** - Already uses ProtolessSimpleAction
|
||||
5. **ProvinceConqueredAction** - Already uses ProtolessSimpleAction, only needs `common_unit` migration
|
||||
|
||||
### 🔄 Conversion Strategy Updates
|
||||
|
||||
**Revised Approach Based on Analysis:**
|
||||
|
||||
1. **Focus on Core Dependencies First**
|
||||
- Migrate `battalion_type` model (unlocks 2 commands immediately)
|
||||
- Migrate `action_result` model (unlocks 12+ commands)
|
||||
- Migrate `available_command`/`selected_command` (unlocks UI layer)
|
||||
|
||||
2. **Leverage Existing Success**
|
||||
- 77.5% of commands already fully migrated
|
||||
- Use migrated commands as reference implementations
|
||||
- Diplomacy helpers prove complex business logic can work with Scala models
|
||||
|
||||
3. **Group Related Migrations**
|
||||
- Military commands: `ArmTroopsCommand`, `TrainCommand`, `OrganizeTroopsCommand`
|
||||
- UI commands: All using `available_command`/`selected_command`
|
||||
- Diplomacy commands: All `Resolve*Command` variants
|
||||
|
||||
---
|
||||
|
||||
*Updated on 2025-09-17 - Analysis based on BUILD.bazel dependencies and code review*
|
||||
*Latest update: ResolvedEagleUnit migrated to use Option[BattalionT] for proper battalion handling*
|
||||
@@ -1,206 +0,0 @@
|
||||
# Occupants Vector Optimization - Conversion Report
|
||||
|
||||
## Overview
|
||||
|
||||
This document details the implementation of an embedded occupants vector in the GameState flatbuffer to replace O(n)
|
||||
unit iteration with O(1) position lookups. It also catalogs all Occupant() and KnownEnemyOccupant() calls that could not
|
||||
be converted to use the new optimized methods.
|
||||
|
||||
## Completed Conversions
|
||||
|
||||
### Successfully Converted Occupant() Calls (16 total)
|
||||
|
||||
#### Commands Directory (11 conversions)
|
||||
|
||||
1. **HideCommand.cpp**:
|
||||
- Line 43: `Occupant(currentState->units(), target)` → `currentState.GetOccupant(target)`
|
||||
- Line 59: `Occupant(currentState->units(), adjCoords)` → `currentState.GetOccupant(adjCoords)`
|
||||
|
||||
2. **ScoutCommand.cpp**:
|
||||
- Line 63: `Occupant(currentState->units(), target)` → `currentState.GetOccupant(target)`
|
||||
- Line 73: `Occupant(currentState->units(), adjacentCoords)` → `currentState.GetOccupant(adjacentCoords)`
|
||||
|
||||
3. **ReduceCommand.cpp**:
|
||||
- Line 66: `Occupant(currentState->units(), target)` → `currentState.GetOccupant(target)`
|
||||
|
||||
4. **RaiseDeadCommand.cpp**:
|
||||
- Line 53: `Occupant(currentState->units(), target)` → `currentState.GetOccupant(target)`
|
||||
|
||||
5. **HolyWaveCommand.cpp**:
|
||||
- Line 233: `Occupant(runningState->units(), coords)` → `runningState.GetOccupant(coords)`
|
||||
|
||||
6. **MoveCommand.cpp**:
|
||||
- Line 66: `Occupant(allUnits, destination)` → `currentState.GetOccupant(destination)`
|
||||
- Line 98: `Occupant(allUnits, adj)` → `currentState.GetOccupant(adj)`
|
||||
- Line 114: `Occupant(allUnits, adj)` → `currentState.GetOccupant(adj)`
|
||||
|
||||
#### Actions Directory (4 conversions)
|
||||
|
||||
1. **UpdateGameStatusAction.cpp**:
|
||||
- Line 232: `Occupant(gameState->units(), criticalTile)` → `currentState.GetOccupant(criticalTile)`
|
||||
|
||||
2. **MeteorCastAction.cpp**:
|
||||
- Line 186: `Occupant(runningGameState->units(), target)` → `runningGameState.GetOccupant(target)`
|
||||
- Line 251: `Occupant(runningGameState->units(), splashCoords)` → `runningGameState.GetOccupant(splashCoords)`
|
||||
- Line 304: `Occupant(runningGameState->units(), coords)` → `runningGameState.GetOccupant(coords)`
|
||||
|
||||
3. **UpdateOpponentKnowledgeAction.cpp**:
|
||||
- Line 42: `Occupant(currentState->units(), adjCoords)` → `currentState.GetOccupant(adjCoords)`
|
||||
|
||||
#### Engine Directory (1 conversion)
|
||||
|
||||
1. **ShardokEngine.cpp**:
|
||||
- Line 463: `Occupant(GetCurrentGameState()->units(), modifiedCoords)` → `gameState.GetOccupant(modifiedCoords)`
|
||||
|
||||
#### Factory Classes Directory (previously converted)
|
||||
|
||||
1. **PlayerSetupCommandFactory.cpp**:
|
||||
- Line 31: `Occupant(gameState->units(), *possiblePosition)` → `gameState.GetOccupant(*possiblePosition)`
|
||||
- Line 40: `Occupant(gameState->units(), possibleHidingPosition)` → `gameState.GetOccupant(possibleHidingPosition)`
|
||||
|
||||
2. **FallIntoWaterAction.cpp**:
|
||||
- Line 154: `Occupant(currentState->units(), adjWithTerrain.adjacentCoords)` →
|
||||
`currentState.GetOccupant(adjWithTerrain.adjacentCoords)`
|
||||
- Line 175: `Occupant(currentState->units(), bestCoords)` → `currentState.GetOccupant(bestCoords)`
|
||||
|
||||
### KnownEnemyOccupant() Conversions
|
||||
|
||||
**Result: 0 conversions possible**
|
||||
|
||||
All KnownEnemyOccupant() calls are in command factory methods that receive decomposed game state parameters (Units*,
|
||||
vector<PlayerId>, etc.) rather than complete GameStateW objects.
|
||||
|
||||
## Remaining Unconverted Calls
|
||||
|
||||
### Occupant() Calls That Cannot Be Converted
|
||||
|
||||
#### 1. PerformUndeadCommandsAction.cpp (2 calls - No GameStateW access)
|
||||
|
||||
- **Line 69**: `Occupant(units, FromCoordsProto(possibleAttackCommandProto.target()))`
|
||||
- **Line 99**: `Occupant(units, adjCoords)`
|
||||
- **Reason**: These calls are in the `ChooseUndeadCommand()` function which only receives `const Units* units`
|
||||
parameter, not a full GameStateW.
|
||||
- **Location**: `src/main/cpp/net/eagle0/shardok/library/actions/PerformUndeadCommandsAction.cpp`
|
||||
|
||||
#### 2. AICommandFilter.cpp (1 call - Raw pointer access)
|
||||
|
||||
- **Line 399**: `KnownEnemyOccupant(pid, units, allyPids, fireLocation)` (in EXTINGUISH_FIRE_COMMAND case)
|
||||
- **Reason**: Method receives `const GameState* gameState` parameter, not GameStateW. Has TODO comment noting this
|
||||
limitation.
|
||||
- **Location**: `src/main/cpp/net/eagle0/shardok/ai/AICommandFilter.cpp`
|
||||
|
||||
#### 3. UpdateGameStatusAction.cpp - Member Variable Usage
|
||||
|
||||
- **Various calls**: Uses `gameState` member variable of type `const GameState*`
|
||||
- **Reason**: Class was designed to take raw GameState pointer in constructor, though InternalExecute method has
|
||||
GameStateW access.
|
||||
- **Location**: `src/main/cpp/net/eagle0/shardok/library/actions/UpdateGameStatusAction.cpp`
|
||||
|
||||
#### 4. IceAndSnowAdjustmentActionFactory.cpp (1 call - Factory pattern)
|
||||
|
||||
- **Line 42**: `Occupant(units, coords)`
|
||||
- **Reason**: Factory method receives individual parameters, not GameStateW.
|
||||
- **Location**: `src/main/cpp/net/eagle0/shardok/library/action_factories/IceAndSnowAdjustmentActionFactory.cpp`
|
||||
|
||||
### KnownEnemyOccupant() Calls That Cannot Be Converted
|
||||
|
||||
#### Command Factory Methods (8 calls - No GameStateW access)
|
||||
|
||||
1. **RepairCommandFactory.cpp** - Line 44
|
||||
2. **FearCommandFactory.cpp** - Line 35
|
||||
3. **LightningBoltCommandFactory.cpp** - Line 54
|
||||
4. **ReduceCommandFactory.cpp** - Line 48
|
||||
5. **ChallengeDuelCommandFactory.cpp** - Line 35
|
||||
6. **HideCommandFactory.cpp** - Line 45
|
||||
7. **MeleeCommandFactory.cpp** - Line 58
|
||||
8. **ArcheryCommandFactory.cpp** - Line 89
|
||||
|
||||
**Common Reason**: All command factory methods follow a pattern where they receive individual game state components (
|
||||
`Units* units`, `vector<PlayerId> allyPids`, etc.) rather than a complete GameStateW object.
|
||||
|
||||
#### Utility Functions (3 calls - Utility function parameters)
|
||||
|
||||
1. **HexMapUtils.cpp** - Lines 81, 670
|
||||
2. **ZoneOfControlCalculator.cpp** - Line 143
|
||||
|
||||
**Reason**: These are utility functions that take decomposed parameters for reusability across different contexts.
|
||||
|
||||
## Performance Impact
|
||||
|
||||
### Achieved Improvements
|
||||
|
||||
- **16 Occupant() calls** converted from O(n) iteration to O(1) lookup
|
||||
- Eliminated cache invalidation issues with thread-local approach
|
||||
- Automatic copying of occupants vector with GameState copies
|
||||
- **Estimated Performance Gain**: 2-5% reduction in AI search time for typical game states
|
||||
|
||||
### Trade-offs
|
||||
|
||||
- **Memory Overhead**: 168 bytes per GameState (14×12 map = 168 int16 values)
|
||||
- **Incremental Updates**: ActionResultApplier now maintains occupants vector via UpdateOccupant() calls
|
||||
- **Copy Cost**: Slightly higher GameState copy overhead offset by O(1) lookup benefits
|
||||
|
||||
## Architectural Patterns Identified
|
||||
|
||||
### Convertible Patterns
|
||||
|
||||
1. **Command InternalExecute methods**: Have access to `const GameStateW& currentState`
|
||||
2. **Action InternalExecute methods**: Have access to `const GameStateW& currentState`
|
||||
3. **Factory methods with GameStateW parameters**: Can access embedded occupants vector
|
||||
|
||||
### Non-Convertible Patterns
|
||||
|
||||
1. **Command Factory methods**: Receive decomposed parameters (`Units*`, `HexMap*`, etc.)
|
||||
2. **Utility functions**: Take individual components for reusability
|
||||
3. **Engine methods**: Often work with raw `GameState*` pointers
|
||||
4. **Legacy member variables**: Classes storing `const GameState*` instead of `GameStateW`
|
||||
|
||||
## Recommendations for Future Work
|
||||
|
||||
### Potential Additional Conversions
|
||||
|
||||
1. **Refactor command factories** to accept GameStateW instead of decomposed parameters
|
||||
2. **Update ShardokEngine** to use GameStateW internally where possible
|
||||
3. **Create GameStateW constructors** from raw GameState* to enable more conversions
|
||||
4. **Modernize legacy classes** to use GameStateW member variables
|
||||
|
||||
### Copy-on-Write Consideration
|
||||
|
||||
The user suggested implementing copy-on-write (COW) for GameStateW to reduce memory allocation overhead during AI
|
||||
search. This could provide additional performance benefits by eliminating unnecessary copying of the occupants vector.
|
||||
|
||||
## Technical Implementation Details
|
||||
|
||||
### Core Changes Made
|
||||
|
||||
1. **game_state.fbs**: Added `occupants:[int16];` field
|
||||
2. **GameStateW.cpp**: Implemented GetOccupant() and UpdateOccupant() methods
|
||||
3. **GameStateCopier.cpp**: Populates occupants vector during GameState creation
|
||||
4. **ActionResultApplier.cpp**: Maintains occupants vector during unit movement
|
||||
|
||||
### Key Method Signatures
|
||||
|
||||
```cpp
|
||||
// O(1) occupant lookup
|
||||
auto GameStateW::GetOccupant(const Coords& coords) const -> const Unit*;
|
||||
|
||||
// O(1) enemy occupant lookup
|
||||
auto GameStateW::GetKnownEnemyOccupant(
|
||||
PlayerId playerId,
|
||||
const std::vector<PlayerId>& allyPids,
|
||||
const Coords& coords) const -> const Unit*;
|
||||
|
||||
// Incremental occupants vector maintenance
|
||||
void GameStateW::UpdateOccupant(
|
||||
UnitId unitId,
|
||||
const Coords& oldCoords,
|
||||
const Coords& newCoords);
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
The occupants vector optimization successfully converted 12 high-frequency Occupant() calls to O(1) lookups while
|
||||
maintaining correctness through automatic copying and incremental updates. The remaining 15+ unconverted calls are
|
||||
primarily in architectural layers (command factories, utilities) that would require broader refactoring to convert. The
|
||||
performance improvement achieved represents a solid foundation that could be extended with future architectural
|
||||
modernization.
|
||||
@@ -1,310 +0,0 @@
|
||||
# Scala 3 Migration: Reflection Issues Found
|
||||
|
||||
This document catalogs all reflection-related problems discovered during the Scala 2.13.16 → Scala 3.7.2 migration of the Eagle0 codebase.
|
||||
|
||||
## Summary
|
||||
|
||||
The migration revealed several categories of reflection issues that needed to be addressed for Scala 3 compatibility:
|
||||
|
||||
1. **Scala 2 Runtime Reflection API** - No longer available in Scala 3
|
||||
2. **Settings System Reflection** - Custom reflection for loading settings singletons
|
||||
3. **json4s Automatic Case Class Extraction** - Uses reflection that fails with Scala 3 metaprogramming classes
|
||||
4. **ScalaTest Exception Handling** - Syntax changes affecting exception variable binding
|
||||
|
||||
## 1. Scala 2 Runtime Reflection (FIXED)
|
||||
|
||||
### Issue
|
||||
Tests using `scala.reflect.runtime.universe` fail because this reflection API doesn't exist in Scala 3.
|
||||
|
||||
### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/test/scala/net/eagle0/eagle/library/actions/types/ActionResultTypesTest.scala`
|
||||
|
||||
### Error
|
||||
```scala
|
||||
import scala.reflect.runtime.universe // Not available in Scala 3
|
||||
```
|
||||
|
||||
### Solution Applied
|
||||
**Deleted the test entirely** as it was redundant. The test was verifying that auto-generated Scala objects (created by Bazel from proto enum values) matched their source proto values - something already guaranteed by the build system. Since the objects are generated directly from the proto definitions, this test provided no value.
|
||||
|
||||
**Files deleted:**
|
||||
- `src/test/scala/net/eagle0/eagle/library/actions/types/ActionResultTypesTest.scala`
|
||||
|
||||
## 2. Settings System Reflection (FIXED)
|
||||
|
||||
### Issue
|
||||
Custom `SettingsLoader` class used reflection to access Scala object singletons, but the reflection pattern changed between Scala 2 and Scala 3.
|
||||
|
||||
### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/library/settings/loaders/SettingsLoader.scala`
|
||||
|
||||
### Error
|
||||
```
|
||||
java.lang.NoSuchMethodException: net.eagle0.eagle.library.settings.ApprehendOutlawVigorCost$.MODULE$
|
||||
```
|
||||
|
||||
### Root Cause
|
||||
In Scala 2, singleton objects are accessed via `ClassName$.MODULE$()`, but in Scala 3, they're accessed directly via `ClassName$` field. Additionally, `scala.reflect.runtime.universe` is not available in Scala 3.
|
||||
|
||||
### Solution Applied
|
||||
**Completely eliminated reflection** by auto-generating the entire `SettingsLoader.scala` file from BUILD.bazel definitions:
|
||||
|
||||
1. **Created generator**: `src/main/go/net/eagle0/build/settings_loader_generator/settings_loader_generator.go` - parses BUILD.bazel and generates complete SettingsLoader.scala with pattern matching for all 272 settings
|
||||
|
||||
2. **Added genrule**: In `src/main/scala/net/eagle0/eagle/library/settings/loaders/BUILD.bazel`:
|
||||
```python
|
||||
genrule(
|
||||
name = "settings_loader_src",
|
||||
srcs = ["//src/main/scala/net/eagle0/eagle/library/settings:BUILD.bazel"],
|
||||
outs = ["SettingsLoader.scala"],
|
||||
cmd = "$(location //src/main/go/net/eagle0/build/settings_loader_generator) $(location //src/main/scala/net/eagle0/eagle/library/settings:BUILD.bazel) > $@",
|
||||
tools = ["//src/main/go/net/eagle0/build/settings_loader_generator"],
|
||||
)
|
||||
```
|
||||
|
||||
3. **Result**: SettingsLoader now uses compile-time pattern matching instead of reflection:
|
||||
```scala
|
||||
private def settingObjectForKey(key: String): Any = key match {
|
||||
case "ActionVigorCost" => ActionVigorCost
|
||||
case "BaseFoodBuyPrice" => BaseFoodBuyPrice
|
||||
// ... all 272 settings auto-generated
|
||||
case _ => throw NoSuchSettingException(key)
|
||||
}
|
||||
```
|
||||
|
||||
### Benefits
|
||||
- **No reflection** - Completely Scala 3 compatible
|
||||
- **Maintainable** - New settings automatically included when added to BUILD.bazel
|
||||
- **Performance** - Pattern matching is faster than reflection
|
||||
- **Type-safe** - Compile-time checking of all settings
|
||||
|
||||
## 3. json4s Reflection Issues (MULTIPLE LOCATIONS)
|
||||
|
||||
### 3.1 EagleServiceImpl JSON Serialization (FIXED)
|
||||
|
||||
#### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/service/EagleServiceImpl.scala`
|
||||
|
||||
#### Error
|
||||
```
|
||||
java.lang.NoClassDefFoundError: scala/quoted/staging/package$
|
||||
```
|
||||
|
||||
#### Root Cause
|
||||
json4s automatic case class serialization uses reflection that tries to access Scala 3 metaprogramming classes (`scala.quoted.staging.package$`) which aren't available at runtime.
|
||||
|
||||
#### Solution Applied
|
||||
Replaced automatic json4s serialization with ScalaPB's built-in JSON support:
|
||||
|
||||
```scala
|
||||
// Old (reflection-based):
|
||||
// implicit val formats: DefaultFormats.type = DefaultFormats
|
||||
// write(actionResultView)
|
||||
|
||||
// New (ScalaPB JSON support):
|
||||
import scalapb.json4s.JsonFormat
|
||||
JsonFormat.toJsonString(actionResultView.toProto)
|
||||
```
|
||||
|
||||
### 3.2 ShardokMapInfo JSON Parsing (FIXED)
|
||||
|
||||
#### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/library/util/ShardokMapInfo.scala` (Line 44)
|
||||
|
||||
#### Error
|
||||
```
|
||||
java.lang.NoClassDefFoundError: scala/quoted/staging/package$
|
||||
at org.json4s.reflect.ScalaSigReader$.readConstructor(ScalaSigReader.scala:42)
|
||||
```
|
||||
|
||||
#### Root Cause
|
||||
The line `val extracted = parsedJson.extract[List[ShardokMapInfo]]` uses json4s automatic case class extraction which relies on reflection.
|
||||
|
||||
#### Solution Applied
|
||||
Replaced automatic extraction with manual JSON parsing:
|
||||
|
||||
```scala
|
||||
// OLD (reflection-based):
|
||||
val extracted = parsedJson.extract[List[ShardokMapInfo]]
|
||||
|
||||
// NEW (manual parsing, no reflection):
|
||||
val extracted = parsedJson match {
|
||||
case JArray(items) => items.map { item =>
|
||||
val name = (item \ "name").extract[String]
|
||||
val castleCount = (item \ "castleCount").extract[Int]
|
||||
val positions = (item \ "positions").extract[Map[Int, Int]]
|
||||
ShardokMapInfo(name, castleCount, positions)
|
||||
}
|
||||
case _ => throw new Exception("Expected JSON array for map info")
|
||||
}
|
||||
```
|
||||
|
||||
#### Testing
|
||||
The fix was verified - `attack_command_chooser_test` now passes successfully.
|
||||
|
||||
### 3.3 HeroNameFetcher JSON Parsing (FIXED)
|
||||
|
||||
#### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/library/util/hero_name_fetcher/HeroNameFetcher.scala`
|
||||
|
||||
#### Issue
|
||||
Case class extraction `parsedJson.extract[ResponseBody]` uses reflection that may fail in Scala 3.
|
||||
|
||||
#### Solution Applied
|
||||
Replaced automatic case class extraction with manual JSON parsing:
|
||||
|
||||
```scala
|
||||
// OLD (reflection-based):
|
||||
val parsedJson = json.parse(src.getLines().mkString)
|
||||
parsedJson.extract[ResponseBody]
|
||||
|
||||
// NEW (manual parsing, no reflection):
|
||||
parsedJson \ "names" match {
|
||||
case JArray(nameArray) =>
|
||||
nameArray.map { nameObj =>
|
||||
val id = (nameObj \ "id").extract[String]
|
||||
val name = (nameObj \ "name").extract[String]
|
||||
NameResponse(id, name)
|
||||
}.toVector
|
||||
case _ => throw new Exception("Expected 'names' array in response")
|
||||
}
|
||||
```
|
||||
|
||||
#### Testing
|
||||
The fix was verified - HeroNameFetcher now builds successfully without reflection.
|
||||
|
||||
### 3.4 Other json4s Usage Analysis
|
||||
|
||||
#### Files with json4s extraction:
|
||||
- **✅ SAFE**: OpenAI/Claude Services - Only extract simple types (`String`, `Int`) - no reflection
|
||||
- **✅ FIXED**: `HeroNameFetcher.scala` - Replaced `extract[ResponseBody]` with manual parsing (no reflection)
|
||||
- **⚠️ POTENTIAL ISSUES** (not currently causing failures but should be monitored):
|
||||
- `JsonUtils.scala`: `extract[Map[String, Vector[String]]]` - complex type extraction
|
||||
- `HexMapJsonUtils.scala`: `extract[List[JObject]]` - may be problematic
|
||||
|
||||
#### Recommendation
|
||||
Apply the same manual parsing pattern to remaining case class extractions if they cause runtime failures during Scala 3 migration.
|
||||
|
||||
## 4. ScalaTest Exception Handling Syntax (FIXED)
|
||||
|
||||
### Issue
|
||||
Scala 3 changed how exception variables are bound in ScalaTest's `the[Exception] thrownBy {...}` construct.
|
||||
|
||||
### Files Affected
|
||||
**70+ test files** across the codebase using exception testing patterns.
|
||||
|
||||
### Error Pattern
|
||||
```
|
||||
Not found: ex
|
||||
```
|
||||
|
||||
### Root Cause
|
||||
In Scala 2: `the[Exception] thrownBy { ... }` automatically creates an `ex` variable.
|
||||
In Scala 3: The exception variable must be explicitly bound.
|
||||
|
||||
### Solution Applied
|
||||
Added explicit variable binding across all affected test files:
|
||||
|
||||
```scala
|
||||
// Old Scala 2 syntax:
|
||||
the[EagleCommandException] thrownBy {
|
||||
// test code
|
||||
}
|
||||
ex.getMessage shouldBe "expected message"
|
||||
|
||||
// New Scala 3 syntax:
|
||||
val ex = the[EagleCommandException] thrownBy {
|
||||
// test code
|
||||
}
|
||||
ex.getMessage shouldBe "expected message"
|
||||
```
|
||||
|
||||
### Script Used
|
||||
Created and ran a systematic fix script that processed 70+ files:
|
||||
|
||||
```bash
|
||||
# Pattern to find and fix exception handling
|
||||
find . -name "*.scala" -exec sed -i '' 's/the\[\([^]]*\)\] thrownBy {/val ex = the[\1] thrownBy {/g' {} \;
|
||||
```
|
||||
|
||||
## 5. ScalaTest Import Changes (FIXED)
|
||||
|
||||
### Issue
|
||||
Scala 3 requires different imports for ScalaTest matchers.
|
||||
|
||||
### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/test/scala/net/eagle0/eagle/library/actions/impl/command/DeclineQuestCommandTest.scala`
|
||||
|
||||
### Error
|
||||
```
|
||||
value convertToAnyShouldWrapper is not a member of object org.scalatest.matchers.should.Matchers
|
||||
```
|
||||
|
||||
### Solution Applied
|
||||
Changed from specific imports to wildcard import:
|
||||
|
||||
```scala
|
||||
// Old:
|
||||
import org.scalatest.matchers.should.Matchers.{convertToAnyShouldWrapper, the}
|
||||
|
||||
// New:
|
||||
import org.scalatest.matchers.should.Matchers.*
|
||||
```
|
||||
|
||||
## 6. Mock Framework Issues (FIXED)
|
||||
|
||||
### Issue
|
||||
ScalaMock had type inference issues with Scala 3 for classes with constructor parameters.
|
||||
|
||||
### Files Affected
|
||||
- `/Users/dancrosby/CodingProjects/github/eagle0/src/test/scala/net/eagle0/eagle/library/EngineImplTest.scala`
|
||||
|
||||
### Error
|
||||
```
|
||||
Found: Vector
|
||||
Required: Vector[net.eagle0.eagle.library.util.hero_generator.hero_with_name.HeroWithName]
|
||||
```
|
||||
|
||||
### Root Cause
|
||||
Mock framework couldn't properly infer types for `mock[HeroGenerator]` where `HeroGenerator` has constructor parameters.
|
||||
|
||||
### Solution Applied
|
||||
The user updated to a newer ScalaMock version that fixed this issue, plus added some missing Bazel dependencies:
|
||||
|
||||
```scala
|
||||
// Also needed to add missing dependency:
|
||||
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution"
|
||||
```
|
||||
|
||||
## Migration Status
|
||||
|
||||
### ✅ COMPLETED
|
||||
- [x] Scala 2 runtime reflection removal
|
||||
- [x] Settings system reflection compatibility
|
||||
- [x] EagleServiceImpl json4s → ScalaPB JSON
|
||||
- [x] ScalaTest exception handling syntax (70+ files)
|
||||
- [x] ScalaTest import changes
|
||||
- [x] Mock framework issues (via ScalaMock update)
|
||||
- [x] All test compilation issues resolved
|
||||
|
||||
### ⚠️ REMAINING
|
||||
- [ ] **Potential json4s case class extractions** - May cause runtime failures (JsonUtils, HexMapJsonUtils) - currently no test failures reported
|
||||
|
||||
### 📊 PROGRESS
|
||||
- **Tests passing**: All identified runtime failures resolved
|
||||
- **Build failures**: 0 (all tests now compile)
|
||||
- **Runtime failures**: 0 (critical ShardokMapInfo issue resolved)
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **✅ COMPLETED**: ShardokMapInfo json4s reflection issue resolved with manual parsing
|
||||
2. **Monitor remaining json4s usage**: Watch for runtime failures in HeroNameFetcher, JsonUtils, and HexMapJsonUtils during full Scala 3 migration
|
||||
3. **Consider ScalaPB for new JSON needs**: For new functionality, prefer ScalaPB's JSON support to avoid reflection entirely
|
||||
4. **Apply manual parsing pattern**: If other json4s case class extractions cause runtime failures, use the same manual parsing approach demonstrated in ShardokMapInfo
|
||||
|
||||
## Key Learnings
|
||||
|
||||
- **Scala 3 reflection changes**: Major differences in singleton object access patterns
|
||||
- **json4s compatibility**: Automatic case class extraction doesn't work well with Scala 3 metaprogramming
|
||||
- **ScalaPB advantage**: Using ScalaPB's JSON support avoids reflection issues entirely
|
||||
- **Systematic approach**: Many issues followed patterns that could be fixed with scripts across multiple files
|
||||
Binary file not shown.
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
cc_library(
|
||||
name = "gtl",
|
||||
hdrs = glob(["include/gtl/*.hpp"]),
|
||||
includes = ["include"],
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
cc_library(
|
||||
name = "parallel_hashmap",
|
||||
hdrs = glob(["parallel_hashmap/*.h"]),
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
@@ -9,7 +9,6 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/config v1.28.10
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.51
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2
|
||||
google.golang.org/grpc v1.68.0
|
||||
google.golang.org/protobuf v1.36.3
|
||||
)
|
||||
|
||||
|
||||
@@ -40,8 +40,6 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0=
|
||||
google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA=
|
||||
google.golang.org/protobuf v1.26.0-rc.1 h1:7QnIQpGRHE5RnLKnESfDoxm2dTapTZua5a0kS0A+VXQ=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU=
|
||||
|
||||
+167
-251
@@ -1,10 +1,9 @@
|
||||
{
|
||||
"__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL",
|
||||
"__INPUT_ARTIFACTS_HASH": 289080209,
|
||||
"__RESOLVED_ARTIFACTS_HASH": -131178107,
|
||||
"__INPUT_ARTIFACTS_HASH": 644967262,
|
||||
"__RESOLVED_ARTIFACTS_HASH": -595552834,
|
||||
"conflict_resolution": {
|
||||
"com.google.guava:failureaccess:1.0.1": "com.google.guava:failureaccess:1.0.2",
|
||||
"com.squareup.okio:okio:2.10.0": "com.squareup.okio:okio:3.6.0",
|
||||
"io.netty:netty-buffer:4.1.110.Final": "io.netty:netty-buffer:4.1.112.Final",
|
||||
"io.netty:netty-codec-http2:4.1.110.Final": "io.netty:netty-codec-http2:4.1.112.Final",
|
||||
"io.netty:netty-codec-http:4.1.110.Final": "io.netty:netty-codec-http:4.1.112.Final",
|
||||
@@ -15,7 +14,8 @@
|
||||
"io.netty:netty-transport-native-unix-common:4.1.110.Final": "io.netty:netty-transport-native-unix-common:4.1.112.Final",
|
||||
"io.netty:netty-transport:4.1.110.Final": "io.netty:netty-transport:4.1.112.Final",
|
||||
"io.opencensus:opencensus-api:0.31.0": "io.opencensus:opencensus-api:0.31.1",
|
||||
"org.checkerframework:checker-qual:3.12.0": "org.checkerframework:checker-qual:3.43.0"
|
||||
"org.checkerframework:checker-qual:3.12.0": "org.checkerframework:checker-qual:3.43.0",
|
||||
"org.scala-lang:scala-library:2.13.14": "org.scala-lang:scala-library:2.13.15"
|
||||
},
|
||||
"artifacts": {
|
||||
"com.amazonaws:aws-lambda-java-core": {
|
||||
@@ -156,18 +156,6 @@
|
||||
},
|
||||
"version": "1.4.2"
|
||||
},
|
||||
"com.squareup.okhttp3:okhttp": {
|
||||
"shasums": {
|
||||
"jar": "b1050081b14bb7a3a7e55a4d3ef01b5dcfabc453b4573a4fc019767191d5f4e0"
|
||||
},
|
||||
"version": "4.12.0"
|
||||
},
|
||||
"com.squareup.okhttp3:okhttp-sse": {
|
||||
"shasums": {
|
||||
"jar": "bff4fbcaef7aac2d910d4ff46dafaa4e6d15da127df6bac97216da46943a7d4c"
|
||||
},
|
||||
"version": "4.12.0"
|
||||
},
|
||||
"com.squareup.okhttp:okhttp": {
|
||||
"shasums": {
|
||||
"jar": "88ac9fd1bb51f82bcc664cc1eb9c225c90dc4389d660231b4cc737bebfe7d0aa"
|
||||
@@ -176,39 +164,27 @@
|
||||
},
|
||||
"com.squareup.okio:okio": {
|
||||
"shasums": {
|
||||
"jar": "8e63292e5c53bb93c4a6b0c213e79f15990fed250c1340f1c343880e1c9c39b5"
|
||||
"jar": "a27f091d34aa452e37227e2cfa85809f29012a8ef2501a9b5a125a978e4fcbc1"
|
||||
},
|
||||
"version": "3.6.0"
|
||||
"version": "2.10.0"
|
||||
},
|
||||
"com.squareup.okio:okio-jvm": {
|
||||
"com.thesamet.scalapb:compilerplugin_2.13": {
|
||||
"shasums": {
|
||||
"jar": "67543f0736fc422ae927ed0e504b98bc5e269fda0d3500579337cb713da28412"
|
||||
},
|
||||
"version": "3.6.0"
|
||||
},
|
||||
"com.thesamet.scalapb:compilerplugin_3": {
|
||||
"shasums": {
|
||||
"jar": "e7d7156269fc23cbb539eea60f07c3230aa05a726434fc942b040495567f0a2d"
|
||||
"jar": "218640423ba8156f994d6d700ef960d65025f79a5918070c0898213f4384df1f"
|
||||
},
|
||||
"version": "1.0.0-alpha.1"
|
||||
},
|
||||
"com.thesamet.scalapb:lenses_3": {
|
||||
"com.thesamet.scalapb:lenses_2.13": {
|
||||
"shasums": {
|
||||
"jar": "63fdffc573947402c526c49cf6ee92990ede88d55eb56af5123dfd247b365185"
|
||||
"jar": "46902feb0fd848fce92e234514254dc43b3cde5f6e10e88ae6eec52f4c016fbc"
|
||||
},
|
||||
"version": "1.0.0-alpha.1"
|
||||
},
|
||||
"com.thesamet.scalapb:protoc-bridge_2.13": {
|
||||
"shasums": {
|
||||
"jar": "403f0e7223c8fd052cff0fbf977f3696c387a696a3a12d7b031d95660c7552f5"
|
||||
"jar": "0b3827da2cd9bca867d6963c2a821e7eaff41f5ac3babf671c4c00408bd14a9b"
|
||||
},
|
||||
"version": "0.9.7"
|
||||
},
|
||||
"com.thesamet.scalapb:protoc-bridge_3": {
|
||||
"shasums": {
|
||||
"jar": "e7e2f1862f54076b6870bd034a7c16aae7b88cfee3d00b69dbb6b1175108560c"
|
||||
},
|
||||
"version": "0.9.9"
|
||||
"version": "0.9.8"
|
||||
},
|
||||
"com.thesamet.scalapb:protoc-gen_2.13": {
|
||||
"shasums": {
|
||||
@@ -216,24 +192,30 @@
|
||||
},
|
||||
"version": "0.9.7"
|
||||
},
|
||||
"com.thesamet.scalapb:scalapb-json4s_3": {
|
||||
"com.thesamet.scalapb:scalapb-json4s_2.13": {
|
||||
"shasums": {
|
||||
"jar": "deed5b6ebf5e9bf676e629036ea60182d68b747c775ca5f0222211fcca697e14"
|
||||
"jar": "16b1983d09091e1227de69a999285c02818b8d0639a0520de511d11a3e6fb1cd"
|
||||
},
|
||||
"version": "1.0.0-alpha.1"
|
||||
},
|
||||
"com.thesamet.scalapb:scalapb-runtime-grpc_3": {
|
||||
"com.thesamet.scalapb:scalapb-runtime-grpc_2.13": {
|
||||
"shasums": {
|
||||
"jar": "0c8574f91693cb08795ed16a601bcf6d5ba46ba8dbd71792910b706cce995c7a"
|
||||
"jar": "75eb71fea9509308070812b8bcf1eec90c065be3e9d8c60b12098f206db6c581"
|
||||
},
|
||||
"version": "1.0.0-alpha.1"
|
||||
},
|
||||
"com.thesamet.scalapb:scalapb-runtime_3": {
|
||||
"com.thesamet.scalapb:scalapb-runtime_2.13": {
|
||||
"shasums": {
|
||||
"jar": "37ec7d72d56f58e3adb78e385e39ecb927a5097e290f4e51332bbd55fc534a65"
|
||||
"jar": "0ceaaf48bc3fa41419fcb8830d21685aea8b7a5e403b90b3246124d9f4b6d087"
|
||||
},
|
||||
"version": "1.0.0-alpha.1"
|
||||
},
|
||||
"com.thoughtworks.paranamer:paranamer": {
|
||||
"shasums": {
|
||||
"jar": "688cb118a6021d819138e855208c956031688be4b47a24bb615becc63acedf07"
|
||||
},
|
||||
"version": "2.8"
|
||||
},
|
||||
"commons-codec:commons-codec": {
|
||||
"shasums": {
|
||||
"jar": "f9f6cb103f2ddc3c99a9d80ada2ae7bf0685111fd6bffccb72033d1da4e6ff23"
|
||||
@@ -463,27 +445,15 @@
|
||||
},
|
||||
"org.jetbrains.kotlin:kotlin-stdlib": {
|
||||
"shasums": {
|
||||
"jar": "55e989c512b80907799f854309f3bc7782c5b3d13932442d0379d5c472711504"
|
||||
"jar": "b8ab1da5cdc89cb084d41e1f28f20a42bd431538642a5741c52bbfae3fa3e656"
|
||||
},
|
||||
"version": "1.9.10"
|
||||
"version": "1.4.20"
|
||||
},
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-common": {
|
||||
"shasums": {
|
||||
"jar": "cde3341ba18a2ba262b0b7cf6c55b20c90e8d434e42c9a13e6a3f770db965a88"
|
||||
"jar": "a7112c9b3cefee418286c9c9372f7af992bd1e6e030691d52f60cb36dbec8320"
|
||||
},
|
||||
"version": "1.9.10"
|
||||
},
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk7": {
|
||||
"shasums": {
|
||||
"jar": "ac6361bf9ad1ed382c2103d9712c47cdec166232b4903ed596e8876b0681c9b7"
|
||||
},
|
||||
"version": "1.9.10"
|
||||
},
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk8": {
|
||||
"shasums": {
|
||||
"jar": "a4c74d94d64ce1abe53760fe0389dd941f6fc558d0dab35e47c085a11ec80f28"
|
||||
},
|
||||
"version": "1.9.10"
|
||||
"version": "1.4.20"
|
||||
},
|
||||
"org.jetbrains:annotations": {
|
||||
"shasums": {
|
||||
@@ -491,35 +461,41 @@
|
||||
},
|
||||
"version": "13.0"
|
||||
},
|
||||
"org.json4s:json4s-ast_3": {
|
||||
"org.json4s:json4s-ast_2.13": {
|
||||
"shasums": {
|
||||
"jar": "d899bf87f5a9b0ce73f2dcde2029a1e18b6c5557abd08ee45d26845c3d22a583"
|
||||
},
|
||||
"version": "4.1.0-M8"
|
||||
},
|
||||
"org.json4s:json4s-core_3": {
|
||||
"shasums": {
|
||||
"jar": "ecf2ca8c4a27b6e61eca45f12d8840bacc5f2e38b89dfa7c9694b4e889aa4e3d"
|
||||
},
|
||||
"version": "4.1.0-M8"
|
||||
},
|
||||
"org.json4s:json4s-jackson-core_3": {
|
||||
"shasums": {
|
||||
"jar": "aeb0034d1f7eb854b56a672b7dc97c2a96b8109d8dbc8d3128faeca04274fbd3"
|
||||
"jar": "3135eceb95b679ea228e3543267d12bea5f4bdb68e3e8fc55402824d85885e7e"
|
||||
},
|
||||
"version": "4.0.7"
|
||||
},
|
||||
"org.json4s:json4s-native-core_3": {
|
||||
"org.json4s:json4s-core_2.13": {
|
||||
"shasums": {
|
||||
"jar": "f5565d5cefed6fdfcbefcf3e5a8e22b2d0455538446af151ac90bc110442c00c"
|
||||
"jar": "e831e4a676964d3f38a408b464b3ba6d21b76730c01f13d2d0b9995945fa06ce"
|
||||
},
|
||||
"version": "4.1.0-M8"
|
||||
"version": "4.0.7"
|
||||
},
|
||||
"org.json4s:json4s-native_3": {
|
||||
"org.json4s:json4s-jackson-core_2.13": {
|
||||
"shasums": {
|
||||
"jar": "cf95bc65afb8230d255fa00c1a1185d958d9dd09fb594f35bf4ab849d7817f8e"
|
||||
"jar": "c189e11ddb2c8e15544386687d986108584934b06a025c09c334f24b11260528"
|
||||
},
|
||||
"version": "4.1.0-M8"
|
||||
"version": "4.0.7"
|
||||
},
|
||||
"org.json4s:json4s-native-core_2.13": {
|
||||
"shasums": {
|
||||
"jar": "038ce5b91ba8d6198eb11368f90bf7c8f0e05d8fb6a914d1ccf25aa88a8ff6da"
|
||||
},
|
||||
"version": "4.0.7"
|
||||
},
|
||||
"org.json4s:json4s-native_2.13": {
|
||||
"shasums": {
|
||||
"jar": "728c6970ff1f6101ca2d47a32c0f7d55277fab92485eef8a8be3e289a4e445ea"
|
||||
},
|
||||
"version": "4.0.7"
|
||||
},
|
||||
"org.json4s:json4s-scalap_2.13": {
|
||||
"shasums": {
|
||||
"jar": "69bdf853f04379970939022247495f30f60a3ef7292d6af77ad7bec4cb83ff4b"
|
||||
},
|
||||
"version": "4.0.7"
|
||||
},
|
||||
"org.ow2.asm:asm": {
|
||||
"shasums": {
|
||||
@@ -533,29 +509,29 @@
|
||||
},
|
||||
"version": "1.0.4"
|
||||
},
|
||||
"org.scala-lang.modules:scala-collection-compat_3": {
|
||||
"org.scala-lang.modules:scala-collection-compat_2.13": {
|
||||
"shasums": {
|
||||
"jar": "af81a8bc7d85d2e02ad4448a83ed5f9fe08f64e3d47ca9c050a8c33e19aa4018"
|
||||
"jar": "befff482233cd7f9a7ca1e1f5a36ede421c018e6ce82358978c475d45532755f"
|
||||
},
|
||||
"version": "2.12.0"
|
||||
},
|
||||
"org.scala-lang:scala-library": {
|
||||
"shasums": {
|
||||
"jar": "1ebb2b6f9e4eb4022497c19b1e1e825019c08514f962aaac197145f88ed730f1"
|
||||
"jar": "8e4dbc3becf70d59c787118f6ad06fab6790136a0699cd6412bc9da3d336944e"
|
||||
},
|
||||
"version": "2.13.16"
|
||||
"version": "2.13.15"
|
||||
},
|
||||
"org.scala-lang:scala3-library_3": {
|
||||
"org.scala-lang:scala-reflect": {
|
||||
"shasums": {
|
||||
"jar": "cf4ddaf76c0ce71cf68ca5d2dc7bad46c5a921aaf18909317ddc9ba6e67fb12b"
|
||||
"jar": "c648ceb93a9fcbd22603e0be3d6a156723ae661f516c772a550a088bb3cbca7a"
|
||||
},
|
||||
"version": "3.3.6"
|
||||
"version": "2.13.12"
|
||||
},
|
||||
"org.scalamock:scalamock_3": {
|
||||
"org.scalamock:scalamock_2.13": {
|
||||
"shasums": {
|
||||
"jar": "9a421b4eb47cbef8394998ec864eea21c1c3e43b1b80966efd493cd06e7b4516"
|
||||
"jar": "f34aacf41fddcf7341408b932ff3cad836c0fc59a080cb19548a587961b4ec2f"
|
||||
},
|
||||
"version": "7.4.1"
|
||||
"version": "6.0.0"
|
||||
},
|
||||
"org.slf4j:slf4j-api": {
|
||||
"shasums": {
|
||||
@@ -810,63 +786,48 @@
|
||||
"org.checkerframework:checker-qual",
|
||||
"org.ow2.asm:asm"
|
||||
],
|
||||
"com.squareup.okhttp3:okhttp": [
|
||||
"com.squareup.okio:okio",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk8"
|
||||
],
|
||||
"com.squareup.okhttp3:okhttp-sse": [
|
||||
"com.squareup.okhttp3:okhttp",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk8"
|
||||
],
|
||||
"com.squareup.okhttp:okhttp": [
|
||||
"com.squareup.okio:okio"
|
||||
],
|
||||
"com.squareup.okio:okio": [
|
||||
"com.squareup.okio:okio-jvm"
|
||||
"org.jetbrains.kotlin:kotlin-stdlib",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-common"
|
||||
],
|
||||
"com.squareup.okio:okio-jvm": [
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-common",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk8"
|
||||
],
|
||||
"com.thesamet.scalapb:compilerplugin_3": [
|
||||
"com.thesamet.scalapb:compilerplugin_2.13": [
|
||||
"com.google.protobuf:protobuf-java",
|
||||
"com.thesamet.scalapb:protoc-gen_2.13",
|
||||
"org.scala-lang.modules:scala-collection-compat_3",
|
||||
"org.scala-lang:scala3-library_3"
|
||||
"org.scala-lang.modules:scala-collection-compat_2.13",
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"com.thesamet.scalapb:lenses_3": [
|
||||
"org.scala-lang.modules:scala-collection-compat_3",
|
||||
"org.scala-lang:scala3-library_3"
|
||||
"com.thesamet.scalapb:lenses_2.13": [
|
||||
"org.scala-lang.modules:scala-collection-compat_2.13",
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"com.thesamet.scalapb:protoc-bridge_2.13": [
|
||||
"dev.dirs:directories",
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"com.thesamet.scalapb:protoc-bridge_3": [
|
||||
"dev.dirs:directories",
|
||||
"org.scala-lang:scala3-library_3"
|
||||
],
|
||||
"com.thesamet.scalapb:protoc-gen_2.13": [
|
||||
"com.thesamet.scalapb:protoc-bridge_2.13",
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"com.thesamet.scalapb:scalapb-json4s_3": [
|
||||
"com.thesamet.scalapb:scalapb-runtime_3",
|
||||
"org.json4s:json4s-jackson-core_3",
|
||||
"org.scala-lang:scala3-library_3"
|
||||
"com.thesamet.scalapb:scalapb-json4s_2.13": [
|
||||
"com.thesamet.scalapb:scalapb-runtime_2.13",
|
||||
"org.json4s:json4s-jackson-core_2.13",
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"com.thesamet.scalapb:scalapb-runtime-grpc_3": [
|
||||
"com.thesamet.scalapb:scalapb-runtime_3",
|
||||
"com.thesamet.scalapb:scalapb-runtime-grpc_2.13": [
|
||||
"com.thesamet.scalapb:scalapb-runtime_2.13",
|
||||
"io.grpc:grpc-protobuf",
|
||||
"io.grpc:grpc-stub",
|
||||
"org.scala-lang.modules:scala-collection-compat_3",
|
||||
"org.scala-lang:scala3-library_3"
|
||||
"org.scala-lang.modules:scala-collection-compat_2.13",
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"com.thesamet.scalapb:scalapb-runtime_3": [
|
||||
"com.thesamet.scalapb:scalapb-runtime_2.13": [
|
||||
"com.google.protobuf:protobuf-java",
|
||||
"com.thesamet.scalapb:lenses_3",
|
||||
"org.scala-lang.modules:scala-collection-compat_3",
|
||||
"org.scala-lang:scala3-library_3"
|
||||
"com.thesamet.scalapb:lenses_2.13",
|
||||
"org.scala-lang.modules:scala-collection-compat_2.13",
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"io.grpc:grpc-api": [
|
||||
"com.google.code.findbugs:jsr305",
|
||||
@@ -1034,42 +995,41 @@
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-common",
|
||||
"org.jetbrains:annotations"
|
||||
],
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk7": [
|
||||
"org.jetbrains.kotlin:kotlin-stdlib"
|
||||
],
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk8": [
|
||||
"org.jetbrains.kotlin:kotlin-stdlib",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk7"
|
||||
],
|
||||
"org.json4s:json4s-ast_3": [
|
||||
"org.scala-lang:scala3-library_3"
|
||||
],
|
||||
"org.json4s:json4s-core_3": [
|
||||
"org.json4s:json4s-ast_3",
|
||||
"org.scala-lang:scala3-library_3"
|
||||
],
|
||||
"org.json4s:json4s-jackson-core_3": [
|
||||
"com.fasterxml.jackson.core:jackson-databind",
|
||||
"org.json4s:json4s-ast_3",
|
||||
"org.scala-lang:scala3-library_3"
|
||||
],
|
||||
"org.json4s:json4s-native-core_3": [
|
||||
"org.json4s:json4s-ast_3",
|
||||
"org.scala-lang:scala3-library_3"
|
||||
],
|
||||
"org.json4s:json4s-native_3": [
|
||||
"org.json4s:json4s-core_3",
|
||||
"org.json4s:json4s-native-core_3",
|
||||
"org.scala-lang:scala3-library_3"
|
||||
],
|
||||
"org.scala-lang.modules:scala-collection-compat_3": [
|
||||
"org.scala-lang:scala3-library_3"
|
||||
],
|
||||
"org.scala-lang:scala3-library_3": [
|
||||
"org.json4s:json4s-ast_2.13": [
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"org.scalamock:scalamock_3": [
|
||||
"org.scala-lang:scala3-library_3"
|
||||
"org.json4s:json4s-core_2.13": [
|
||||
"com.thoughtworks.paranamer:paranamer",
|
||||
"org.json4s:json4s-ast_2.13",
|
||||
"org.json4s:json4s-scalap_2.13",
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"org.json4s:json4s-jackson-core_2.13": [
|
||||
"com.fasterxml.jackson.core:jackson-databind",
|
||||
"org.json4s:json4s-ast_2.13",
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"org.json4s:json4s-native-core_2.13": [
|
||||
"org.json4s:json4s-ast_2.13",
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"org.json4s:json4s-native_2.13": [
|
||||
"org.json4s:json4s-core_2.13",
|
||||
"org.json4s:json4s-native-core_2.13",
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"org.json4s:json4s-scalap_2.13": [
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"org.scala-lang.modules:scala-collection-compat_2.13": [
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"org.scala-lang:scala-reflect": [
|
||||
"org.scala-lang:scala-library"
|
||||
],
|
||||
"org.scalamock:scalamock_2.13": [
|
||||
"org.scala-lang:scala-library",
|
||||
"org.scala-lang:scala-reflect"
|
||||
],
|
||||
"org.slf4j:slf4j-simple": [
|
||||
"org.slf4j:slf4j-api"
|
||||
@@ -1500,29 +1460,6 @@
|
||||
"com.google.truth:truth": [
|
||||
"com.google.common.truth"
|
||||
],
|
||||
"com.squareup.okhttp3:okhttp": [
|
||||
"okhttp3",
|
||||
"okhttp3.internal",
|
||||
"okhttp3.internal.authenticator",
|
||||
"okhttp3.internal.cache",
|
||||
"okhttp3.internal.cache2",
|
||||
"okhttp3.internal.concurrent",
|
||||
"okhttp3.internal.connection",
|
||||
"okhttp3.internal.http",
|
||||
"okhttp3.internal.http1",
|
||||
"okhttp3.internal.http2",
|
||||
"okhttp3.internal.io",
|
||||
"okhttp3.internal.platform",
|
||||
"okhttp3.internal.platform.android",
|
||||
"okhttp3.internal.proxy",
|
||||
"okhttp3.internal.publicsuffix",
|
||||
"okhttp3.internal.tls",
|
||||
"okhttp3.internal.ws"
|
||||
],
|
||||
"com.squareup.okhttp3:okhttp-sse": [
|
||||
"okhttp3.internal.sse",
|
||||
"okhttp3.sse"
|
||||
],
|
||||
"com.squareup.okhttp:okhttp": [
|
||||
"com.squareup.okhttp",
|
||||
"com.squareup.okhttp.internal",
|
||||
@@ -1531,18 +1468,18 @@
|
||||
"com.squareup.okhttp.internal.io",
|
||||
"com.squareup.okhttp.internal.tls"
|
||||
],
|
||||
"com.squareup.okio:okio-jvm": [
|
||||
"com.squareup.okio:okio": [
|
||||
"okio",
|
||||
"okio.internal"
|
||||
],
|
||||
"com.thesamet.scalapb:compilerplugin_3": [
|
||||
"com.thesamet.scalapb:compilerplugin_2.13": [
|
||||
"scalapb",
|
||||
"scalapb.compiler",
|
||||
"scalapb.internal",
|
||||
"scalapb.options",
|
||||
"scalapb.options.compiler"
|
||||
],
|
||||
"com.thesamet.scalapb:lenses_3": [
|
||||
"com.thesamet.scalapb:lenses_2.13": [
|
||||
"scalapb.lenses"
|
||||
],
|
||||
"com.thesamet.scalapb:protoc-bridge_2.13": [
|
||||
@@ -1550,21 +1487,16 @@
|
||||
"protocbridge.codegen",
|
||||
"protocbridge.frontend"
|
||||
],
|
||||
"com.thesamet.scalapb:protoc-bridge_3": [
|
||||
"protocbridge",
|
||||
"protocbridge.codegen",
|
||||
"protocbridge.frontend"
|
||||
],
|
||||
"com.thesamet.scalapb:protoc-gen_2.13": [
|
||||
"protocgen"
|
||||
],
|
||||
"com.thesamet.scalapb:scalapb-json4s_3": [
|
||||
"com.thesamet.scalapb:scalapb-json4s_2.13": [
|
||||
"scalapb.json4s"
|
||||
],
|
||||
"com.thesamet.scalapb:scalapb-runtime-grpc_3": [
|
||||
"com.thesamet.scalapb:scalapb-runtime-grpc_2.13": [
|
||||
"scalapb.grpc"
|
||||
],
|
||||
"com.thesamet.scalapb:scalapb-runtime_3": [
|
||||
"com.thesamet.scalapb:scalapb-runtime_2.13": [
|
||||
"com.google.protobuf.any",
|
||||
"com.google.protobuf.api",
|
||||
"com.google.protobuf.compiler.plugin",
|
||||
@@ -1583,6 +1515,9 @@
|
||||
"scalapb.options",
|
||||
"scalapb.textformat"
|
||||
],
|
||||
"com.thoughtworks.paranamer:paranamer": [
|
||||
"com.thoughtworks.paranamer"
|
||||
],
|
||||
"commons-codec:commons-codec": [
|
||||
"org.apache.commons.codec",
|
||||
"org.apache.commons.codec.binary",
|
||||
@@ -1886,7 +1821,6 @@
|
||||
"kotlin.annotation",
|
||||
"kotlin.collections",
|
||||
"kotlin.collections.builders",
|
||||
"kotlin.collections.jdk8",
|
||||
"kotlin.collections.unsigned",
|
||||
"kotlin.comparisons",
|
||||
"kotlin.concurrent",
|
||||
@@ -1895,59 +1829,51 @@
|
||||
"kotlin.coroutines.cancellation",
|
||||
"kotlin.coroutines.intrinsics",
|
||||
"kotlin.coroutines.jvm.internal",
|
||||
"kotlin.enums",
|
||||
"kotlin.experimental",
|
||||
"kotlin.internal",
|
||||
"kotlin.internal.jdk7",
|
||||
"kotlin.internal.jdk8",
|
||||
"kotlin.io",
|
||||
"kotlin.io.encoding",
|
||||
"kotlin.io.path",
|
||||
"kotlin.jdk7",
|
||||
"kotlin.js",
|
||||
"kotlin.jvm",
|
||||
"kotlin.jvm.functions",
|
||||
"kotlin.jvm.internal",
|
||||
"kotlin.jvm.internal.markers",
|
||||
"kotlin.jvm.internal.unsafe",
|
||||
"kotlin.jvm.jdk8",
|
||||
"kotlin.jvm.optionals",
|
||||
"kotlin.math",
|
||||
"kotlin.properties",
|
||||
"kotlin.random",
|
||||
"kotlin.random.jdk8",
|
||||
"kotlin.ranges",
|
||||
"kotlin.reflect",
|
||||
"kotlin.sequences",
|
||||
"kotlin.streams.jdk8",
|
||||
"kotlin.system",
|
||||
"kotlin.text",
|
||||
"kotlin.text.jdk8",
|
||||
"kotlin.time",
|
||||
"kotlin.time.jdk8"
|
||||
"kotlin.time"
|
||||
],
|
||||
"org.jetbrains:annotations": [
|
||||
"org.intellij.lang.annotations",
|
||||
"org.jetbrains.annotations"
|
||||
],
|
||||
"org.json4s:json4s-ast_3": [
|
||||
"org.json4s:json4s-ast_2.13": [
|
||||
"org.json4s",
|
||||
"org.json4s.prefs"
|
||||
],
|
||||
"org.json4s:json4s-core_3": [
|
||||
"org.json4s:json4s-core_2.13": [
|
||||
"org.json4s",
|
||||
"org.json4s.prefs",
|
||||
"org.json4s.reflect"
|
||||
],
|
||||
"org.json4s:json4s-jackson-core_3": [
|
||||
"org.json4s:json4s-jackson-core_2.13": [
|
||||
"org.json4s.jackson"
|
||||
],
|
||||
"org.json4s:json4s-native-core_3": [
|
||||
"org.json4s:json4s-native-core_2.13": [
|
||||
"org.json4s.native"
|
||||
],
|
||||
"org.json4s:json4s-native_3": [
|
||||
"org.json4s:json4s-native_2.13": [
|
||||
"org.json4s.native"
|
||||
],
|
||||
"org.json4s:json4s-scalap_2.13": [
|
||||
"org.json4s.scalap",
|
||||
"org.json4s.scalap.scalasig"
|
||||
],
|
||||
"org.ow2.asm:asm": [
|
||||
"org.objectweb.asm",
|
||||
"org.objectweb.asm.signature"
|
||||
@@ -1955,7 +1881,7 @@
|
||||
"org.reactivestreams:reactive-streams": [
|
||||
"org.reactivestreams"
|
||||
],
|
||||
"org.scala-lang.modules:scala-collection-compat_3": [
|
||||
"org.scala-lang.modules:scala-collection-compat_2.13": [
|
||||
"scala.collection.compat",
|
||||
"scala.collection.compat.immutable",
|
||||
"scala.util.control.compat",
|
||||
@@ -1994,26 +1920,22 @@
|
||||
"scala.util.hashing",
|
||||
"scala.util.matching"
|
||||
],
|
||||
"org.scala-lang:scala3-library_3": [
|
||||
"scala",
|
||||
"scala.annotation",
|
||||
"scala.annotation.internal",
|
||||
"scala.annotation.unchecked",
|
||||
"scala.compiletime",
|
||||
"scala.compiletime.ops",
|
||||
"scala.compiletime.testing",
|
||||
"scala.deriving",
|
||||
"scala.quoted",
|
||||
"scala.quoted.runtime",
|
||||
"scala.reflect",
|
||||
"scala.runtime",
|
||||
"scala.runtime.coverage",
|
||||
"scala.runtime.function",
|
||||
"scala.runtime.stdLibPatches",
|
||||
"scala.util",
|
||||
"scala.util.control"
|
||||
"org.scala-lang:scala-reflect": [
|
||||
"scala.reflect.api",
|
||||
"scala.reflect.internal",
|
||||
"scala.reflect.internal.annotations",
|
||||
"scala.reflect.internal.pickling",
|
||||
"scala.reflect.internal.settings",
|
||||
"scala.reflect.internal.tpe",
|
||||
"scala.reflect.internal.transform",
|
||||
"scala.reflect.internal.util",
|
||||
"scala.reflect.io",
|
||||
"scala.reflect.macros",
|
||||
"scala.reflect.macros.blackbox",
|
||||
"scala.reflect.macros.whitebox",
|
||||
"scala.reflect.runtime"
|
||||
],
|
||||
"org.scalamock:scalamock_3": [
|
||||
"org.scalamock:scalamock_2.13": [
|
||||
"org.scalamock",
|
||||
"org.scalamock.clazz",
|
||||
"org.scalamock.context",
|
||||
@@ -2024,8 +1946,6 @@
|
||||
"org.scalamock.scalatest",
|
||||
"org.scalamock.scalatest.proxy",
|
||||
"org.scalamock.specs2",
|
||||
"org.scalamock.stubs",
|
||||
"org.scalamock.stubs.internal",
|
||||
"org.scalamock.util"
|
||||
],
|
||||
"org.slf4j:slf4j-api": [
|
||||
@@ -2355,19 +2275,16 @@
|
||||
"com.google.protobuf:protobuf-java",
|
||||
"com.google.re2j:re2j",
|
||||
"com.google.truth:truth",
|
||||
"com.squareup.okhttp3:okhttp",
|
||||
"com.squareup.okhttp3:okhttp-sse",
|
||||
"com.squareup.okhttp:okhttp",
|
||||
"com.squareup.okio:okio",
|
||||
"com.squareup.okio:okio-jvm",
|
||||
"com.thesamet.scalapb:compilerplugin_3",
|
||||
"com.thesamet.scalapb:lenses_3",
|
||||
"com.thesamet.scalapb:compilerplugin_2.13",
|
||||
"com.thesamet.scalapb:lenses_2.13",
|
||||
"com.thesamet.scalapb:protoc-bridge_2.13",
|
||||
"com.thesamet.scalapb:protoc-bridge_3",
|
||||
"com.thesamet.scalapb:protoc-gen_2.13",
|
||||
"com.thesamet.scalapb:scalapb-json4s_3",
|
||||
"com.thesamet.scalapb:scalapb-runtime-grpc_3",
|
||||
"com.thesamet.scalapb:scalapb-runtime_3",
|
||||
"com.thesamet.scalapb:scalapb-json4s_2.13",
|
||||
"com.thesamet.scalapb:scalapb-runtime-grpc_2.13",
|
||||
"com.thesamet.scalapb:scalapb-runtime_2.13",
|
||||
"com.thoughtworks.paranamer:paranamer",
|
||||
"commons-codec:commons-codec",
|
||||
"commons-logging:commons-logging",
|
||||
"dev.dirs:directories",
|
||||
@@ -2412,20 +2329,19 @@
|
||||
"org.hamcrest:hamcrest-core",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-common",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk7",
|
||||
"org.jetbrains.kotlin:kotlin-stdlib-jdk8",
|
||||
"org.jetbrains:annotations",
|
||||
"org.json4s:json4s-ast_3",
|
||||
"org.json4s:json4s-core_3",
|
||||
"org.json4s:json4s-jackson-core_3",
|
||||
"org.json4s:json4s-native-core_3",
|
||||
"org.json4s:json4s-native_3",
|
||||
"org.json4s:json4s-ast_2.13",
|
||||
"org.json4s:json4s-core_2.13",
|
||||
"org.json4s:json4s-jackson-core_2.13",
|
||||
"org.json4s:json4s-native-core_2.13",
|
||||
"org.json4s:json4s-native_2.13",
|
||||
"org.json4s:json4s-scalap_2.13",
|
||||
"org.ow2.asm:asm",
|
||||
"org.reactivestreams:reactive-streams",
|
||||
"org.scala-lang.modules:scala-collection-compat_3",
|
||||
"org.scala-lang.modules:scala-collection-compat_2.13",
|
||||
"org.scala-lang:scala-library",
|
||||
"org.scala-lang:scala3-library_3",
|
||||
"org.scalamock:scalamock_3",
|
||||
"org.scala-lang:scala-reflect",
|
||||
"org.scalamock:scalamock_2.13",
|
||||
"org.slf4j:slf4j-api",
|
||||
"org.slf4j:slf4j-simple",
|
||||
"software.amazon.awssdk:annotations",
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# AI Performance Test Runner Script
|
||||
# Runs the AI performance test with optimized builds and 10 turns
|
||||
|
||||
echo "Running AI performance test with optimized build..."
|
||||
echo "=============================================="
|
||||
|
||||
# Run with optimized compilation and 10 turns
|
||||
bazel run -c opt //src/main/cpp/net/eagle0/shardok/ai_performance_runner:ai_performance_runner -- --turns=10 "$@"
|
||||
@@ -3,8 +3,7 @@
|
||||
set -euxo pipefail
|
||||
|
||||
/bin/echo "building darwin bundle"
|
||||
bazel build --config=mactools @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle
|
||||
ZIP_LOCATION=$(bazel cquery --config=mactools --output=files @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle 2>/dev/null)
|
||||
/usr/bin/unzip -o $ZIP_LOCATION -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
|
||||
bazel build --noincompatible_enable_cc_toolchain_resolution @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle
|
||||
/usr/bin/unzip -o bazel-bin/external/net_eagle0_unity_godice/darwin/framework/DarwinGodiceBundle.zip -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
|
||||
|
||||
/usr/bin/plutil -convert xml1 src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/DarwinGodiceBundle.bundle/Contents/Info.plist
|
||||
|
||||
@@ -5,9 +5,8 @@ set -euxo pipefail
|
||||
/bin/echo "build plugins"
|
||||
|
||||
/bin/echo "building darwin bundle"
|
||||
bazel build --config=mactools @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle
|
||||
ZIP_LOCATION=$(bazel cquery --config=mactools --output=files @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle 2>/dev/null)
|
||||
/usr/bin/unzip -o $ZIP_LOCATION -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
|
||||
bazel build --noincompatible_enable_cc_toolchain_resolution @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle
|
||||
/usr/bin/unzip -o bazel-bin/external/net_eagle0_unity_godice/darwin/framework/DarwinGodiceBundle.zip -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
|
||||
|
||||
/usr/bin/plutil -convert xml1 src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/DarwinGodiceBundle.bundle/Contents/Info.plist
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1pv-WMXReccddPwev_YG9IXEGznuGHrYjNNEZ0Rb-ZhM/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/shardok/settings.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1p6I5nUMcoAPHIcqikVgbBCFVnqN9dpOEVClbS_wOI7M/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/eagle/settings.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1pv-WMXReccddPwev_YG9IXEGznuGHrYjNNEZ0Rb-ZhM/export?gid=0&format=tsv" > src/main/resources/net/eagle0/shardok/settings.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1p6I5nUMcoAPHIcqikVgbBCFVnqN9dpOEVClbS_wOI7M/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/settings.tsv
|
||||
|
||||
bazel run //src/main/go/net/eagle0/build/settings_generator:settings_generator -- \
|
||||
${PWD}/src/main/resources/net/eagle0/eagle/settings.tsv \
|
||||
${PWD}/src/main/scala/net/eagle0/eagle/library/settings/
|
||||
bazel run gazelle
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1DHEsiv4cY4gE6AX3sVH82K__mpBD1aznIYCQwQxA_F0/export?gid=0&format=tsv" | tr -d '\r' > /tmp/names.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1DHEsiv4cY4gE6AX3sVH82K__mpBD1aznIYCQwQxA_F0/export?gid=0&format=tsv" > /tmp/names.tsv
|
||||
bazel run //src/main/scala/net/eagle0/util:name_list_checker -- /tmp/names.tsv > src/main/resources/net/eagle0/names.tsv
|
||||
bazel run //src/main/scala/net/eagle0/util:name_list_json_maker -- /tmp/names.tsv > src/main/resources/net/eagle0/names.json
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1NhvG73HKyVE36yGpkV2oJiSIXoNqQOYTr5ArLnucYL0/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/shardok/battalionTypes.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1pNWiyxIks2wJ1v7jRLFD24zrKHG2AfhC-nkWmQKQGN4/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/eagle/heroes.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1RUguq5eAQprsZwOOqiCc-1dg4Urc_6iJ6awZsFU4MeI/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/eagle/beasts.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1NhvG73HKyVE36yGpkV2oJiSIXoNqQOYTr5ArLnucYL0/export?gid=0&format=tsv" > src/main/resources/net/eagle0/shardok/battalionTypes.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1pNWiyxIks2wJ1v7jRLFD24zrKHG2AfhC-nkWmQKQGN4/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/heroes.tsv
|
||||
curl -L "https://docs.google.com/spreadsheets/d/1RUguq5eAQprsZwOOqiCc-1dg4Urc_6iJ6awZsFU4MeI/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/beasts.tsv
|
||||
#curl -L "https://docs.google.com/spreadsheets/d/1Z-60cJ_N1IasvqpVb5awKEkIYznEeR2IZSdli47oW88/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/province_map.tsv
|
||||
|
||||
${PWD}/scripts/dlSettings.sh
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# generate_changelog.sh
|
||||
#
|
||||
# Generates a weekly changelog from merged PRs, uses Claude to create a synopsis,
|
||||
# and opens an email draft in Mac Mail.
|
||||
#
|
||||
# Usage: ./scripts/generate_changelog.sh [--dry-run]
|
||||
#
|
||||
# The script tracks its last run using a git tag 'changelog-last-run'.
|
||||
# On first run (no tag), it defaults to the previous Friday at 4pm.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Ensure homebrew binaries are in PATH
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
TAG_NAME="changelog-last-run"
|
||||
DRY_RUN=false
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Usage: $0 [--dry-run]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Get the cutoff date - either from tag or previous Friday 4pm
|
||||
get_cutoff_date() {
|
||||
# Try to get the date from the tag
|
||||
if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then
|
||||
# Get the commit date of the tagged commit
|
||||
git log -1 --format="%aI" "$TAG_NAME"
|
||||
else
|
||||
# Calculate previous Friday at 4pm
|
||||
# Get current day of week (1=Monday, 7=Sunday)
|
||||
local dow=$(date +%u)
|
||||
local days_since_friday
|
||||
|
||||
if [[ $dow -ge 5 ]]; then
|
||||
# Friday (5), Saturday (6), or Sunday (7)
|
||||
days_since_friday=$((dow - 5))
|
||||
else
|
||||
# Monday (1) through Thursday (4)
|
||||
days_since_friday=$((dow + 2))
|
||||
fi
|
||||
|
||||
# Get previous Friday at 4pm in ISO format
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
date -v-"${days_since_friday}d" -v16H -v0M -v0S +"%Y-%m-%dT%H:%M:%S%z"
|
||||
else
|
||||
date -d "$days_since_friday days ago 16:00:00" --iso-8601=seconds
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Fetch merged PRs since the cutoff date
|
||||
fetch_merged_prs() {
|
||||
local since_date="$1"
|
||||
local output_file="$2"
|
||||
|
||||
echo "Fetching PRs merged since: $since_date"
|
||||
|
||||
# Use gh to search for merged PRs
|
||||
gh pr list \
|
||||
--state merged \
|
||||
--base main \
|
||||
--json number,title,body,mergedAt,author \
|
||||
--jq ".[] | select(.mergedAt >= \"$since_date\")" \
|
||||
> "$output_file.json"
|
||||
|
||||
# Format the output nicely
|
||||
echo "# Merged PRs since $since_date" > "$output_file"
|
||||
echo "" >> "$output_file"
|
||||
|
||||
# Process each PR
|
||||
jq -r '
|
||||
"## PR #\(.number): \(.title)\n" +
|
||||
"Author: \(.author.login)\n" +
|
||||
"Merged: \(.mergedAt)\n\n" +
|
||||
"### Description\n" +
|
||||
(.body // "(No description)") +
|
||||
"\n\n---\n"
|
||||
' "$output_file.json" >> "$output_file"
|
||||
|
||||
# Count PRs
|
||||
local pr_count=$(jq -s 'length' "$output_file.json")
|
||||
echo "Found $pr_count merged PRs"
|
||||
|
||||
rm -f "$output_file.json"
|
||||
|
||||
if [[ $pr_count -eq 0 ]]; then
|
||||
echo "No PRs found since $since_date"
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Generate synopsis using Claude
|
||||
generate_synopsis() {
|
||||
local input_file="$1"
|
||||
local output_file="$2"
|
||||
|
||||
echo "Generating synopsis with Claude..."
|
||||
|
||||
# Create a prompt file to avoid shell escaping issues
|
||||
local prompt_file="/tmp/eagle0_prompt_$$.txt"
|
||||
cat > "$prompt_file" <<'PROMPT_HEADER'
|
||||
You are summarizing changes for a weekly engineering update email.
|
||||
|
||||
Read the following list of merged PRs and create a concise synopsis grouped by theme/feature/area of the codebase.
|
||||
|
||||
Guidelines:
|
||||
- Group related changes together under clear headings (ALL CAPS with a blank line before)
|
||||
- Use "- " bullet points for individual changes
|
||||
- Highlight any significant new features, breaking changes, or important fixes
|
||||
- Keep the tone professional but accessible
|
||||
- Aim for a summary that takes 1-2 minutes to read
|
||||
- Don't include PR numbers in the summary - focus on what changed and why it matters
|
||||
|
||||
IMPORTANT: Output plain text only. No markdown, no HTML. Use simple formatting:
|
||||
- ALL CAPS for section headers
|
||||
- Dashes (-) for bullet points
|
||||
- Blank lines between sections
|
||||
|
||||
Here are the merged PRs:
|
||||
|
||||
PROMPT_HEADER
|
||||
|
||||
cat "$input_file" >> "$prompt_file"
|
||||
echo "" >> "$prompt_file"
|
||||
echo "Generate the synopsis now:" >> "$prompt_file"
|
||||
|
||||
# Use Claude CLI to generate the synopsis (read from stdin)
|
||||
cat "$prompt_file" | claude --print > "$output_file"
|
||||
|
||||
rm -f "$prompt_file"
|
||||
echo "Synopsis generated at: $output_file"
|
||||
}
|
||||
|
||||
# Create email in Mac Mail
|
||||
create_email() {
|
||||
local synopsis_file="$1"
|
||||
local since_date="$2"
|
||||
|
||||
local subject="Eagle0 Weekly Changelog - $(date +%Y-%m-%d)"
|
||||
|
||||
echo "Creating email draft in Mac Mail..."
|
||||
|
||||
osascript <<EOF
|
||||
set textContent to read POSIX file "$synopsis_file" as «class utf8»
|
||||
|
||||
tell application "Mail"
|
||||
set newMessage to make new outgoing message with properties {subject:"$subject", content:textContent, visible:true}
|
||||
activate
|
||||
end tell
|
||||
EOF
|
||||
|
||||
echo "Email draft created"
|
||||
}
|
||||
|
||||
# Update the tag to mark this run
|
||||
update_tag() {
|
||||
echo "Updating $TAG_NAME tag..."
|
||||
|
||||
# Delete existing tag if present
|
||||
git tag -d "$TAG_NAME" 2>/dev/null || true
|
||||
git push origin --delete "$TAG_NAME" 2>/dev/null || true
|
||||
|
||||
# Create new tag at HEAD
|
||||
git tag "$TAG_NAME"
|
||||
git push origin "$TAG_NAME"
|
||||
|
||||
echo "Tag updated to current HEAD"
|
||||
}
|
||||
|
||||
# Main
|
||||
main() {
|
||||
echo "=== Eagle0 Weekly Changelog Generator ==="
|
||||
echo ""
|
||||
|
||||
# Get cutoff date
|
||||
local cutoff_date=$(get_cutoff_date)
|
||||
echo "Cutoff date: $cutoff_date"
|
||||
|
||||
# Create temp files
|
||||
local pr_file="/tmp/eagle0_prs_$(date +%s).md"
|
||||
local synopsis_file="/tmp/eagle0_synopsis_$(date +%s).txt"
|
||||
|
||||
# Fetch PRs
|
||||
if ! fetch_merged_prs "$cutoff_date" "$pr_file"; then
|
||||
echo "No changes to report. Exiting."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "PR details saved to: $pr_file"
|
||||
|
||||
# Generate synopsis
|
||||
generate_synopsis "$pr_file" "$synopsis_file"
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo ""
|
||||
echo "=== DRY RUN - Synopsis content ==="
|
||||
cat "$synopsis_file"
|
||||
echo ""
|
||||
echo "=== DRY RUN - Skipping email creation and tag update ==="
|
||||
else
|
||||
# Create email
|
||||
create_email "$synopsis_file" "$cutoff_date"
|
||||
|
||||
# Update tag for next run
|
||||
update_tag
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done!"
|
||||
echo "PR details: $pr_file"
|
||||
echo "Synopsis: $synopsis_file"
|
||||
}
|
||||
|
||||
main
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/bin/zsh
|
||||
|
||||
echo "***"
|
||||
echo "*** Moving files to workspace"
|
||||
mv /Users/dancrosby/NewInvokeAI/outputs/images/*.png /Users/dancrosby/Downloads/new_heroes/
|
||||
|
||||
echo "***"
|
||||
echo "*** Renaming files"
|
||||
bazel run src/main/go/net/eagle0/util/hero_generation/pngorganizer -- /Users/dancrosby/Downloads/new_heroes/
|
||||
|
||||
# echo "***"
|
||||
# echo "*** Moving files to generated"
|
||||
# mv /Users/dancrosby/Downloads/new_heroes/generated/*.png /Users/dancrosby/Documents/headshots/generated
|
||||
|
||||
# echo "***"
|
||||
# echo "*** Syncing to server"
|
||||
# ./scripts/sync_headshots.sh
|
||||
|
||||
# echo "***"
|
||||
# echo "*** Checking which new heroes have images and adjusting TSVs"
|
||||
# bazel run //src/main/go/net/eagle0/util/hero_generation/imagechecker -- /Users/dancrosby/CodingProjects/github/eagle0/src/main/resources/net/eagle0/eagle/waiting_headshots_heroes.herodata /Users/dancrosby/CodingProjects/github/eagle0/src/main/resources/net/eagle0/eagle/generated_heroes.tsv /Users/dancrosby/Documents/headshots/
|
||||
|
||||
# echo "***"
|
||||
# echo "*** Deduplicate names"
|
||||
# bazel run //src/main/go/net/eagle0/util/hero_generation/namededuplicator /Users/dancrosby/CodingProjects/github/eagle0/src/main/resources/net/eagle0/eagle/generated_heroes.tsv
|
||||
# rm src/main/resources/net/eagle0/eagle/generated_heroes.tsv.backup
|
||||
|
||||
# echo "***"
|
||||
# echo "*** Generating new SD prompts"
|
||||
# bazel run src/main/go/net/eagle0/util/hero_generation/heroformatter ${PWD}/src/main/resources/net/eagle0/eagle/waiting_headshots_heroes.herodata ~/samplelines.txt
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Pre-commit hook wrapper for gazelle that fails if files are modified.
|
||||
# This ensures BUILD files are in canonical format before committing.
|
||||
|
||||
set -e
|
||||
|
||||
# Run gazelle
|
||||
bazel run //:gazelle 2>/dev/null
|
||||
|
||||
# Check if any BUILD files were modified
|
||||
if ! git diff --quiet -- '*.bazel' '**/BUILD' 'WORKSPACE*'; then
|
||||
echo ""
|
||||
echo "ERROR: gazelle modified BUILD files. Please stage the changes and retry:"
|
||||
echo ""
|
||||
git diff --name-only -- '*.bazel' '**/BUILD' 'WORKSPACE*'
|
||||
echo ""
|
||||
echo "Run: git add -u && git commit"
|
||||
exit 1
|
||||
fi
|
||||
@@ -22,6 +22,13 @@ cc_library(
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "container_utils",
|
||||
hdrs = ["ContainerUtils.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "filesystem_utils",
|
||||
srcs = ["FilesystemUtils.cpp"],
|
||||
@@ -88,13 +95,6 @@ cc_library(
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "thread_pool",
|
||||
hdrs = ["ThreadPool.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "time_utils",
|
||||
hdrs = ["TimeUtils.hpp"],
|
||||
|
||||
@@ -7,43 +7,12 @@
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// FNV-1a 64-bit constants
|
||||
constexpr uint64_t FNV_PRIME = 0x00000100000001B3ULL;
|
||||
constexpr uint64_t FNV_OFFSET_BASIS = 0xcbf29ce484222325ULL;
|
||||
constexpr int64_t FNV_PRIME = 0x100000001b3;
|
||||
constexpr int64_t FNV_OFFSET_BASIS = 0xcbf29ce484222325;
|
||||
|
||||
// FNV-1a algorithm: XOR first, then multiply
|
||||
static inline auto MixIn(uint64_t& hash, const uint8_t byte) {
|
||||
hash ^= byte;
|
||||
hash *= FNV_PRIME;
|
||||
}
|
||||
|
||||
// Hash an entire buffer using FNV-1a
|
||||
// Fast word-at-a-time implementation - processes 8 bytes at once for better performance
|
||||
// while maintaining good distribution properties for hash table use
|
||||
static inline auto HashBuffer(const uint8_t* data, size_t size) -> uint64_t {
|
||||
if (data == nullptr) { return FNV_OFFSET_BASIS; }
|
||||
|
||||
uint64_t hash = FNV_OFFSET_BASIS;
|
||||
const uint8_t* end = data + size;
|
||||
|
||||
// Process 8 bytes at a time
|
||||
while (data + 8 <= end) {
|
||||
uint64_t word;
|
||||
// Use memcpy to avoid alignment issues and let compiler optimize
|
||||
__builtin_memcpy(&word, data, sizeof(word));
|
||||
hash ^= word;
|
||||
hash *= FNV_PRIME;
|
||||
data += 8;
|
||||
}
|
||||
|
||||
// Process remaining bytes
|
||||
while (data < end) {
|
||||
hash ^= static_cast<uint64_t>(*data);
|
||||
hash *= FNV_PRIME;
|
||||
data++;
|
||||
}
|
||||
|
||||
return hash;
|
||||
static inline auto MixIn(int64_t& hash, const uint8_t byte) {
|
||||
hash = hash * FNV_PRIME;
|
||||
hash = hash ^ byte;
|
||||
}
|
||||
|
||||
#endif // EAGLE0_BYTEHASHER_HPP
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//
|
||||
// Created by Dan Crosby on 12/25/20.
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_CONTAINERUTILS_HPP
|
||||
#define EAGLE0_CONTAINERUTILS_HPP
|
||||
|
||||
#include <algorithm>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
|
||||
namespace common {
|
||||
|
||||
using std::allocator;
|
||||
using std::back_inserter;
|
||||
using std::begin;
|
||||
using std::copy_if;
|
||||
using std::count_if;
|
||||
using std::end;
|
||||
using std::find;
|
||||
using std::find_if;
|
||||
using std::function;
|
||||
using std::optional;
|
||||
using std::remove_if;
|
||||
using std::vector;
|
||||
|
||||
template<class T, class Container>
|
||||
auto Contains(const Container& container, const T& elt) -> bool {
|
||||
return find(begin(container), end(container), elt) != end(container);
|
||||
}
|
||||
|
||||
template<class Container, class Func>
|
||||
auto CountIf(const Container& container, Func fn) -> size_t {
|
||||
Container result{};
|
||||
return count_if(begin(container), end(container), fn);
|
||||
}
|
||||
|
||||
template<class Container, class Func>
|
||||
void FilterInPlace(Container& container, Func fn) {
|
||||
container.erase(
|
||||
remove_if(begin(container), end(container), [fn](const auto& elt) { return !fn(elt); }),
|
||||
end(container));
|
||||
}
|
||||
|
||||
template<class Container, class Func>
|
||||
auto Filtered(const Container& container, Func fn) -> Container {
|
||||
Container result{};
|
||||
copy_if(begin(container), end(container), back_inserter(result), fn);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
template<class Container, class Func>
|
||||
auto FilteredToVector(const Container& container, Func fn) -> decltype(auto) {
|
||||
typedef typename Container::value_type value_type;
|
||||
vector<value_type> result{};
|
||||
copy_if(begin(container), end(container), back_inserter(result), fn);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
template<typename Container, typename Func>
|
||||
auto FindIf(const Container& container, Func fn) -> optional<typename Container::value_type> {
|
||||
const auto& t = find_if(begin(container), end(container), fn);
|
||||
|
||||
if (t == end(container)) {
|
||||
return {};
|
||||
} else {
|
||||
return optional<typename Container::value_type>(*t);
|
||||
}
|
||||
}
|
||||
|
||||
template<typename Container, typename Func>
|
||||
auto ContainsWhere(const Container& container, Func fn) -> bool {
|
||||
return find_if(begin(container), end(container), fn) != end(container);
|
||||
}
|
||||
|
||||
template<
|
||||
template<typename, typename>
|
||||
class TwoTypeContainer,
|
||||
typename T,
|
||||
typename Allocator = allocator<T>,
|
||||
typename Func>
|
||||
auto Map(const TwoTypeContainer<T, Allocator>& input, Func fn) -> decltype(auto) {
|
||||
typedef typename decltype(function(fn))::result_type result_type;
|
||||
|
||||
TwoTypeContainer<result_type, allocator<result_type>> result{};
|
||||
result.reserve(input.size());
|
||||
|
||||
transform(begin(input), end(input), back_inserter(result), fn);
|
||||
return result;
|
||||
}
|
||||
|
||||
template<template<typename> class OneTypeContainer, typename T, typename Func>
|
||||
auto Map(const OneTypeContainer<T>& input, Func fn) -> decltype(auto) {
|
||||
typedef typename decltype(function(fn))::result_type result_type;
|
||||
|
||||
OneTypeContainer<result_type> result{};
|
||||
result.reserve(input.size());
|
||||
|
||||
transform(begin(input), end(input), back_inserter(result), fn);
|
||||
return result;
|
||||
}
|
||||
|
||||
template<typename Container, typename Func>
|
||||
auto MapToVector(const Container& input, Func fn) -> decltype(auto) {
|
||||
typedef typename decltype(function(fn))::result_type result_type;
|
||||
|
||||
vector<result_type> result{};
|
||||
|
||||
transform(begin(input), end(input), back_inserter(result), fn);
|
||||
return result;
|
||||
}
|
||||
|
||||
template<
|
||||
template<typename, typename>
|
||||
class TwoTypeContainer,
|
||||
typename T,
|
||||
typename Allocator = allocator<T>,
|
||||
typename Func>
|
||||
auto FlatMap(const TwoTypeContainer<T, Allocator>& input, Func fn) -> decltype(auto) {
|
||||
typedef typename decltype(function(fn))::result_type::value_type result_value_type;
|
||||
|
||||
TwoTypeContainer<result_value_type, allocator<result_value_type>> result{};
|
||||
|
||||
for (const auto& elt : input) {
|
||||
const auto& outContainer = fn(elt);
|
||||
for (const auto& outElt : outContainer) { result.push_back(outElt); }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
template<template<typename> class OneTypeContainer, typename T, typename Func>
|
||||
auto FlatMap(const OneTypeContainer<T>& input, Func fn) -> decltype(auto) {
|
||||
typedef typename decltype(function(fn))::result_type::value_type result_value_type;
|
||||
|
||||
OneTypeContainer<result_value_type> result{};
|
||||
|
||||
for (const auto& elt : input) {
|
||||
const auto& outContainer = fn(elt);
|
||||
for (const auto& outElt : outContainer) { result.push_back(outElt); }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
template<typename Container, typename Func>
|
||||
auto FlatMapToVector(const Container& input, Func fn) -> decltype(auto) {
|
||||
typedef typename decltype(function(fn))::result_type::value_type value_type;
|
||||
|
||||
vector<value_type> result{};
|
||||
|
||||
for (const auto& elt : input) {
|
||||
const auto& outContainer = fn(elt);
|
||||
for (const auto& outElt : outContainer) { result.push_back(outElt); }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
template<typename Container>
|
||||
auto ToVector(const Container& input) -> decltype(auto) {
|
||||
typedef typename Container::value_type value_type;
|
||||
return vector<value_type>(begin(input), end(input));
|
||||
}
|
||||
|
||||
template<typename C1, typename C2>
|
||||
auto Append(C1& recipient, const C2& newItems) -> C1& {
|
||||
recipient.insert(end(recipient), begin(newItems), end(newItems));
|
||||
return recipient;
|
||||
}
|
||||
|
||||
} // namespace common
|
||||
|
||||
#endif // EAGLE0_CONTAINERUTILS_HPP
|
||||
@@ -30,7 +30,7 @@ auto rloc(const string& execPath) -> string {
|
||||
const std::unique_ptr<Runfiles> runfiles(Runfiles::Create(execPath, &error));
|
||||
|
||||
if (runfiles == nullptr) {
|
||||
fprintf(stderr, "Error! %s\n", error.c_str());
|
||||
printf("Error! %s\n", error.c_str());
|
||||
abort();
|
||||
// error handling
|
||||
}
|
||||
@@ -67,9 +67,9 @@ auto FilesystemUtils::MapFilesDirectory() -> string {
|
||||
|
||||
void FilesystemUtils::MakeDirectoryIfNecessary(const string& directoryPath) {
|
||||
if (fs::create_directories(directoryPath))
|
||||
fprintf(stderr, "Directory %s created\n", directoryPath.c_str());
|
||||
printf("Directory %s created\n", directoryPath.c_str());
|
||||
else
|
||||
fprintf(stderr, "No new directory created for %s\n", directoryPath.c_str());
|
||||
printf("No new directory created for %s\n", directoryPath.c_str());
|
||||
}
|
||||
|
||||
auto FilesystemUtils::SaveFilesDirectory() -> string {
|
||||
@@ -129,11 +129,11 @@ auto FilesystemUtils::AtomicallySaveToPath(const string& path, const byte_vector
|
||||
if (ostr.good()) {
|
||||
const int err = rename(tempPath.c_str(), path.c_str());
|
||||
if (err == -1) {
|
||||
fprintf(stderr, "Failed to move file to %s! Errno %d\n", path.c_str(), errno);
|
||||
printf("Failed to move file to %s! Errno %d\n", path.c_str(), errno);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
fprintf(stderr, "Failed writing to %s!\n", tempPath.c_str());
|
||||
printf("Failed writing to %s!\n", tempPath.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ auto FilesystemUtils::LoadFromPath(const string& path) -> byte_vector {
|
||||
const std::streamsize size = inputFileStream.tellg();
|
||||
inputFileStream.seekg(0, std::ios::beg);
|
||||
|
||||
auto bv = byte_vector(static_cast<size_t>(size));
|
||||
auto bv = byte_vector(size);
|
||||
inputFileStream.read((char*)bv.data(), size);
|
||||
|
||||
return bv;
|
||||
|
||||
@@ -84,9 +84,7 @@ auto RandomGenerator::ChanceOpenEndedPercentileAtOrAbove(const double value) ->
|
||||
|
||||
auto StdLibraryGenerator::DoubleZeroToOne() -> double { return unifDouble(engine); }
|
||||
|
||||
StdLibraryGenerator::StdLibraryGenerator() : RandomGenerator() {
|
||||
engine.seed(static_cast<std::mt19937_64::result_type>(std::time(nullptr)));
|
||||
}
|
||||
StdLibraryGenerator::StdLibraryGenerator() : RandomGenerator() { engine.seed(std::time(nullptr)); }
|
||||
|
||||
auto StdLibraryGenerator::IntBetween(const int min, const int max) -> int {
|
||||
std::uniform_int_distribution<int> unifInt(min, max - 1);
|
||||
|
||||
@@ -14,14 +14,6 @@
|
||||
|
||||
#include "src/main/cpp/net/eagle0/common/RandomGenerator.hpp"
|
||||
|
||||
// A deterministic random generator that returns values from a fixed sequence.
|
||||
// Used for testing and MCTS simulation where we want specific, predictable outcomes.
|
||||
//
|
||||
// Values in the sequence are treated as [0, 1] probabilities that are returned
|
||||
// by DoubleZeroToOne(). The normal percentile methods (including open-ended
|
||||
// variants) work as usual, so callers must provide appropriate sequences.
|
||||
// For example, to get an open-ended low result of -50, provide [0.02, 0.52]
|
||||
// which produces: initial=2 (triggers open-ended), accumulated=52, final=2-52=-50
|
||||
class SequenceRandomGenerator : public ::RandomGenerator {
|
||||
private:
|
||||
const std::vector<double> sequence;
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
//
|
||||
// ThreadPool.cpp - Implementation of priority-based thread pool
|
||||
//
|
||||
|
||||
#include "ThreadPool.hpp"
|
||||
|
||||
namespace eagle0 {
|
||||
namespace common {
|
||||
|
||||
// Implementation is header-only to support templates
|
||||
// This file exists for potential future non-template implementations
|
||||
|
||||
} // namespace common
|
||||
} // namespace eagle0
|
||||
@@ -1,200 +0,0 @@
|
||||
//
|
||||
// ThreadPool.hpp - Priority-based thread pool with deadline support
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_THREADPOOL_HPP
|
||||
#define EAGLE0_THREADPOOL_HPP
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <functional>
|
||||
#include <future>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <queue>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace eagle0::common {
|
||||
|
||||
enum class TaskStatus { SUCCESS = 0, DEADLINE_EXCEEDED = 1, CANCELLED = 2 };
|
||||
|
||||
template<typename T>
|
||||
struct TaskResult {
|
||||
T value;
|
||||
TaskStatus status;
|
||||
|
||||
TaskResult() : value{}, status(TaskStatus::SUCCESS) {}
|
||||
TaskResult(T val) : value(std::move(val)), status(TaskStatus::SUCCESS) {}
|
||||
TaskResult(T val, TaskStatus stat) : value(std::move(val)), status(stat) {}
|
||||
|
||||
// NO implicit conversion - this was causing infinite recursion
|
||||
// Use .value or .get() instead
|
||||
T get() const { return value; }
|
||||
|
||||
bool succeeded() const { return status == TaskStatus::SUCCESS; }
|
||||
bool deadlineExceeded() const { return status == TaskStatus::DEADLINE_EXCEEDED; }
|
||||
};
|
||||
|
||||
class ThreadPool {
|
||||
public:
|
||||
using Clock = std::chrono::steady_clock;
|
||||
using TimePoint = Clock::time_point;
|
||||
|
||||
private:
|
||||
struct Task {
|
||||
std::function<void()> function;
|
||||
int priority;
|
||||
TimePoint deadline;
|
||||
bool has_deadline;
|
||||
|
||||
Task(std::function<void()> f, int p, TimePoint d, bool has_d)
|
||||
: function(std::move(f)),
|
||||
priority(p),
|
||||
deadline(d),
|
||||
has_deadline(has_d) {}
|
||||
|
||||
// Higher priority values and earlier deadlines have higher priority
|
||||
bool operator<(const Task& other) const {
|
||||
if (priority != other.priority) {
|
||||
return priority < other.priority; // Lower priority values have lower priority in
|
||||
// priority_queue
|
||||
}
|
||||
if (has_deadline && other.has_deadline) {
|
||||
return deadline > other.deadline; // Later deadlines have lower priority
|
||||
}
|
||||
if (has_deadline && !other.has_deadline) {
|
||||
return false; // Tasks with deadlines have higher priority
|
||||
}
|
||||
if (!has_deadline && other.has_deadline) {
|
||||
return true; // Tasks without deadlines have lower priority
|
||||
}
|
||||
return false; // Equal priority, no preference
|
||||
}
|
||||
};
|
||||
|
||||
std::vector<std::thread> workers;
|
||||
std::priority_queue<Task> tasks;
|
||||
std::mutex queue_mutex;
|
||||
std::condition_variable condition;
|
||||
std::atomic<bool> stop{false};
|
||||
|
||||
public:
|
||||
explicit ThreadPool(size_t num_threads = std::thread::hardware_concurrency()) {
|
||||
for (size_t i = 0; i < num_threads; ++i) {
|
||||
workers.emplace_back([this] {
|
||||
while (true) {
|
||||
Task task{nullptr, 0, TimePoint{}, false};
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(queue_mutex);
|
||||
condition.wait(lock, [this] { return stop.load() || !tasks.empty(); });
|
||||
|
||||
if (stop.load() && tasks.empty()) { return; }
|
||||
|
||||
if (!tasks.empty()) {
|
||||
task = std::move(const_cast<Task&>(tasks.top()));
|
||||
tasks.pop();
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the task (deadline checking is now handled inside the task)
|
||||
if (task.function) { task.function(); }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Enqueue a task with priority only
|
||||
template<class F, class... Args>
|
||||
auto enqueue(F&& f, Args&&... args, int priority = 0)
|
||||
-> std::future<TaskResult<std::invoke_result_t<F, Args...>>> {
|
||||
using return_type = std::invoke_result_t<F, Args...>;
|
||||
using result_type = TaskResult<return_type>;
|
||||
|
||||
auto actualTask = std::bind(std::forward<F>(f), std::forward<Args>(args)...);
|
||||
|
||||
auto task = std::make_shared<std::packaged_task<result_type()>>(
|
||||
[actualTask = std::move(actualTask)]() mutable -> result_type {
|
||||
return result_type(actualTask());
|
||||
});
|
||||
|
||||
std::future<result_type> result = task->get_future();
|
||||
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(queue_mutex);
|
||||
if (stop.load()) { throw std::runtime_error("enqueue on stopped ThreadPool"); }
|
||||
tasks.emplace([task]() { (*task)(); }, priority, TimePoint{}, false);
|
||||
}
|
||||
|
||||
condition.notify_one();
|
||||
return result;
|
||||
}
|
||||
|
||||
// Enqueue a task with priority and deadline
|
||||
template<class F, class... Args>
|
||||
auto enqueue_with_deadline(F&& f, Args&&... args, int priority, TimePoint deadline)
|
||||
-> std::future<TaskResult<std::invoke_result_t<F, Args...>>> {
|
||||
using return_type = std::invoke_result_t<F, Args...>;
|
||||
using result_type = TaskResult<return_type>;
|
||||
|
||||
auto actualTask = std::bind(std::forward<F>(f), std::forward<Args>(args)...);
|
||||
|
||||
auto task = std::make_shared<std::packaged_task<result_type()>>(
|
||||
[actualTask = std::move(actualTask), deadline]() mutable -> result_type {
|
||||
if (Clock::now() > deadline) {
|
||||
return result_type(return_type{}, TaskStatus::DEADLINE_EXCEEDED);
|
||||
}
|
||||
return result_type(actualTask());
|
||||
});
|
||||
|
||||
std::future<result_type> result = task->get_future();
|
||||
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(queue_mutex);
|
||||
if (stop.load()) { throw std::runtime_error("enqueue on stopped ThreadPool"); }
|
||||
tasks.emplace([task]() { (*task)(); }, priority, deadline, true);
|
||||
}
|
||||
|
||||
condition.notify_one();
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get current queue size (approximate, for monitoring)
|
||||
size_t queue_size() const {
|
||||
std::unique_lock<std::mutex> lock(const_cast<std::mutex&>(queue_mutex));
|
||||
return tasks.size();
|
||||
}
|
||||
|
||||
// Get detailed queue information for debugging
|
||||
void debug_queue_state() const {
|
||||
std::unique_lock<std::mutex> lock(const_cast<std::mutex&>(queue_mutex));
|
||||
printf("ThreadPool: Queue size: %zu\n", tasks.size());
|
||||
if (!tasks.empty()) {
|
||||
// Create a copy to inspect priorities without modifying queue
|
||||
auto queue_copy = tasks;
|
||||
std::vector<int> priorities;
|
||||
while (!queue_copy.empty()) {
|
||||
priorities.push_back(queue_copy.top().priority);
|
||||
queue_copy.pop();
|
||||
}
|
||||
printf("ThreadPool: Priorities in queue: ");
|
||||
for (int p : priorities) { printf("%d ", p); }
|
||||
printf("\n");
|
||||
}
|
||||
}
|
||||
|
||||
~ThreadPool() {
|
||||
stop.store(true);
|
||||
condition.notify_all();
|
||||
for (std::thread& worker : workers) {
|
||||
if (worker.joinable()) { worker.join(); }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace eagle0::common
|
||||
|
||||
#endif // EAGLE0_THREADPOOL_HPP
|
||||
@@ -8,8 +8,6 @@ namespace shardok {
|
||||
|
||||
using Coords = net::eagle0::shardok::storage::fb::Coords;
|
||||
|
||||
constexpr double kDefaultMorale = 50.0;
|
||||
|
||||
auto ConvertBattalion(const net::eagle0::common::CommonBattalion &battalion) -> Battalion {
|
||||
Battalion shardokBattalion{};
|
||||
|
||||
@@ -17,9 +15,9 @@ auto ConvertBattalion(const net::eagle0::common::CommonBattalion &battalion) ->
|
||||
shardokBattalion.mutate_size(battalion.size());
|
||||
shardokBattalion.mutate_type(
|
||||
static_cast<net::eagle0::shardok::storage::fb::BattalionTypeId>(battalion.type()));
|
||||
shardokBattalion.mutate_morale(kDefaultMorale);
|
||||
shardokBattalion.mutate_armament(static_cast<float>(battalion.armament()));
|
||||
shardokBattalion.mutate_training(static_cast<float>(battalion.training()));
|
||||
shardokBattalion.mutate_morale(battalion.morale());
|
||||
shardokBattalion.mutate_armament(battalion.armament());
|
||||
shardokBattalion.mutate_training(battalion.training());
|
||||
|
||||
return shardokBattalion;
|
||||
}
|
||||
@@ -39,28 +37,28 @@ auto ConvertHero(const net::eagle0::common::CommonHero &hero) -> Hero {
|
||||
shardokHero.mutable_control_info().mutate_controlled_unit_id(-1);
|
||||
shardokHero.mutable_control_info().mutate_controlled_this_round(false);
|
||||
|
||||
shardokHero.mutate_strength(static_cast<int8_t>(hero.strength()));
|
||||
shardokHero.mutate_strength_xp(static_cast<int16_t>(hero.strength_xp()));
|
||||
shardokHero.mutate_strength(hero.strength());
|
||||
shardokHero.mutate_strength_xp(hero.strength_xp());
|
||||
|
||||
shardokHero.mutate_agility(static_cast<int8_t>(hero.agility()));
|
||||
shardokHero.mutate_agility_xp(static_cast<int16_t>(hero.agility_xp()));
|
||||
shardokHero.mutate_agility(hero.agility());
|
||||
shardokHero.mutate_agility_xp(hero.agility_xp());
|
||||
|
||||
shardokHero.mutate_constitution(static_cast<int8_t>(hero.constitution()));
|
||||
shardokHero.mutate_constitution_xp(static_cast<int16_t>(hero.constitution_xp()));
|
||||
shardokHero.mutate_constitution(hero.constitution());
|
||||
shardokHero.mutate_constitution_xp(hero.constitution_xp());
|
||||
|
||||
shardokHero.mutate_charisma(static_cast<int8_t>(hero.charisma()));
|
||||
shardokHero.mutate_charisma_xp(static_cast<int16_t>(hero.charisma_xp()));
|
||||
shardokHero.mutate_charisma(hero.charisma());
|
||||
shardokHero.mutate_charisma_xp(hero.charisma_xp());
|
||||
|
||||
shardokHero.mutate_wisdom(static_cast<int8_t>(hero.wisdom()));
|
||||
shardokHero.mutate_wisdom_xp(static_cast<int16_t>(hero.wisdom_xp()));
|
||||
shardokHero.mutate_wisdom(hero.wisdom());
|
||||
shardokHero.mutate_wisdom_xp(hero.wisdom_xp());
|
||||
|
||||
shardokHero.mutate_integrity(static_cast<int8_t>(hero.integrity()));
|
||||
shardokHero.mutate_ambition(static_cast<int8_t>(hero.ambition()));
|
||||
shardokHero.mutate_gregariousness(static_cast<int8_t>(hero.gregariousness()));
|
||||
shardokHero.mutate_bravery(static_cast<int8_t>(hero.bravery()));
|
||||
shardokHero.mutate_integrity(hero.integrity());
|
||||
shardokHero.mutate_ambition(hero.ambition());
|
||||
shardokHero.mutate_gregariousness(hero.gregariousness());
|
||||
shardokHero.mutate_bravery(hero.bravery());
|
||||
|
||||
shardokHero.mutate_vigor(static_cast<float>(hero.vigor()));
|
||||
shardokHero.mutate_starting_vigor(static_cast<float>(hero.vigor()));
|
||||
shardokHero.mutate_vigor(hero.vigor());
|
||||
shardokHero.mutate_starting_vigor(hero.vigor());
|
||||
|
||||
return shardokHero;
|
||||
}
|
||||
@@ -72,14 +70,7 @@ auto ConvertUnit(
|
||||
Unit shardokUnit{};
|
||||
|
||||
shardokUnit.mutate_player_id(shardokPlayerId);
|
||||
|
||||
// Range check eagle_player_id for int8 conversion
|
||||
int32_t eagle_id = unit.eagle_player_id();
|
||||
if (eagle_id < -128 || eagle_id > 127) {
|
||||
throw std::runtime_error(
|
||||
"eagle_player_id " + std::to_string(eagle_id) + " out of int8 range");
|
||||
}
|
||||
shardokUnit.mutate_eagle_player_id(static_cast<int8_t>(eagle_id));
|
||||
shardokUnit.mutate_eagle_player_id(unit.eagle_player_id());
|
||||
shardokUnit.mutate_hidden(false);
|
||||
shardokUnit.mutate_fortified(false);
|
||||
if (unit.has_hero()) {
|
||||
@@ -95,22 +86,19 @@ auto ConvertUnit(
|
||||
shardokUnit.mutate_stun_rounds_remaining(0);
|
||||
|
||||
for (const PlayerId pid : allPlayerIds) {
|
||||
shardokUnit.mutable_opponent_knowledge()->Mutate(
|
||||
static_cast<flatbuffers::uoffset_t>(pid),
|
||||
0);
|
||||
shardokUnit.mutable_opponent_knowledge()->Mutate(pid, 0);
|
||||
}
|
||||
|
||||
shardokUnit.mutate_has_moved_in_zoc(false);
|
||||
shardokUnit.mutate_targeted_unit(-1);
|
||||
shardokUnit.mutate_volleys_remaining(0);
|
||||
shardokUnit.mutate_food_remaining(static_cast<float>(unit.food()));
|
||||
shardokUnit.mutate_food_remaining(unit.food());
|
||||
shardokUnit.mutate_can_flee(unit.can_flee());
|
||||
shardokUnit.mutate_can_archery(unit.can_archery());
|
||||
shardokUnit.mutate_can_start_fire(unit.can_start_fire());
|
||||
|
||||
if (unit.has_starting_position_index()) {
|
||||
shardokUnit.mutate_starting_position_index(
|
||||
static_cast<int8_t>(unit.starting_position_index().value()));
|
||||
shardokUnit.mutate_starting_position_index(unit.starting_position_index().value());
|
||||
} else {
|
||||
shardokUnit.mutate_starting_position_index(-1);
|
||||
}
|
||||
|
||||
@@ -9,10 +9,7 @@
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
|
||||
#include "src/main/flatbuffer/net/eagle0/shardok/storage/unit.hpp"
|
||||
#pragma GCC diagnostic push
|
||||
#pragma GCC diagnostic ignored "-Wdeprecated-redundant-constexpr-static-def"
|
||||
#include "src/main/protobuf/net/eagle0/common/common_unit.pb.h"
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
namespace shardok {
|
||||
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
# MCTS (Monte Carlo Tree Search) Framework
|
||||
|
||||
This directory contains a game-agnostic Monte Carlo Tree Search implementation that can be used with any turn-based game. The framework separates the MCTS algorithm from game-specific logic through abstract interfaces.
|
||||
|
||||
## Core Abstract Classes
|
||||
|
||||
### `MCTSAction` (abstract/MCTSAction.hpp)
|
||||
Abstract interface for representing game actions/moves.
|
||||
|
||||
**Key Methods:**
|
||||
- `getIndex()` - Returns the action's unique identifier
|
||||
- `getDescription()` - Human-readable description for debugging/logging
|
||||
- `clone()` - Creates a deep copy of the action
|
||||
- `equals()` - Compares actions for equality
|
||||
|
||||
### `MCTSGameState` (abstract/MCTSGameState.hpp)
|
||||
Abstract interface for representing game states.
|
||||
|
||||
**Key Methods:**
|
||||
- `hash()` - Returns a hash for transposition table lookups
|
||||
- `score(playerId)` - Evaluates the state's value for a given player
|
||||
- `currentPlayerId()` - Returns whose turn it is
|
||||
- `isTerminal()` - Checks if the game has ended
|
||||
- `getWinner()` - Returns the winning player (if terminal)
|
||||
- `clone()` - Creates a deep copy of the state
|
||||
- `equals()` - Compares states for equality
|
||||
|
||||
### `MCTSGameEngine` (abstract/MCTSGameEngine.hpp)
|
||||
Abstract interface for game rule enforcement and state transitions. Many methods have efficient default implementations.
|
||||
|
||||
**Must Override (Pure Virtual):**
|
||||
- `applyAction(state, action)` - Applies an action to create a new state
|
||||
- `getLegalActions(state)` - Returns all valid moves from a state
|
||||
- `isTerminal(state)` - Checks if a state is game-ending
|
||||
- `evaluateState(state, playerId)` - Scores a state for a player
|
||||
|
||||
**Optional Overrides (Have Default Implementations):**
|
||||
- `applyActionMutable(state, action)` - Apply action in-place for efficiency (default: calls applyAction)
|
||||
- `filterActions(actions, state)` - Applies heuristic filtering (default: no filtering)
|
||||
- `simulateRandomPlayout(state, playerId, maxDepth, policy)` - Runs simulation (default: efficient mutable implementation)
|
||||
- `getActionScore(state, action, playerId)` - Scores an action (default: apply and evaluate)
|
||||
- `shouldStopSearch(state, iterations, startTime)` - Early termination (default: no early stop)
|
||||
|
||||
**Performance Features:**
|
||||
- The default `simulateRandomPlayout` clones the state once and mutates it throughout simulation for efficiency
|
||||
- Games can override `applyActionMutable` to provide even more efficient in-place updates
|
||||
- Games can override `simulateRandomPlayout` for custom optimizations (e.g., using internal engine state)
|
||||
|
||||
## MCTS Algorithm Implementation
|
||||
|
||||
### `AbstractMCTSAI` (abstract/AbstractMCTSAI.hpp)
|
||||
The main MCTS algorithm implementation that works with any game implementing the abstract interfaces.
|
||||
|
||||
**Key Features:**
|
||||
- **Selection**: Uses UCB1 (Upper Confidence Bound) for node selection
|
||||
- **Expansion**: Adds new nodes to the search tree
|
||||
- **Simulation**: Runs random playouts to estimate node values
|
||||
- **Backpropagation**: Updates node statistics with simulation results
|
||||
- **Multithreading**: Supports parallel MCTS with configurable thread count
|
||||
- **Path Compression**: Optimizes move sequences for better performance
|
||||
|
||||
**Configuration Options:**
|
||||
- `explorationConstant` - UCB1 exploration parameter (default: √2)
|
||||
- `maxSimulationDepth` - Maximum depth for random playouts
|
||||
- `maxTreeDepth` - Maximum tree depth to prevent stack overflow
|
||||
- `useMultithreading` - Enable parallel search
|
||||
- `numThreads` - Number of worker threads
|
||||
- `simulationPolicy` - Strategy for action selection during simulation
|
||||
|
||||
### `MCTSNode` (abstract/MCTSNode.hpp)
|
||||
Represents nodes in the MCTS search tree.
|
||||
|
||||
**Core Data:**
|
||||
- `action` - The action that led to this node
|
||||
- `actionIndex` - Index in the original actions array
|
||||
- `gameState` - The game state at this node
|
||||
- `visitCount` - Number of times this node was visited
|
||||
- `totalReward` - Sum of simulation rewards
|
||||
- `averageReward` - Average reward (totalReward / visitCount)
|
||||
- `children` - Child nodes in the search tree
|
||||
- `parent` - Parent node reference
|
||||
|
||||
**Key Methods:**
|
||||
- `CanExpand()` - Checks if node has untried actions
|
||||
- `GetBestChild(explorationConstant)` - UCB1-based child selection
|
||||
- `GetBestFinalChild()` - Most-visited child (for final move selection)
|
||||
- `CalculateUCB1(explorationConstant)` - Computes UCB1 value
|
||||
|
||||
## Simulation Policies
|
||||
|
||||
The framework supports multiple strategies for action selection during random playouts:
|
||||
|
||||
- **RANDOM** - Uniform random selection
|
||||
- **FILTERED_RANDOM** - Random selection from filtered action set
|
||||
- **BEST_IMMEDIATE** - Always choose the highest-scoring immediate action
|
||||
- **WEIGHTED_BEST_IMMEDIATE** - Weighted random selection based on action scores
|
||||
|
||||
## Type Definitions
|
||||
|
||||
### `MCTSTypes` (abstract/MCTSTypes.hpp)
|
||||
- `MCTSPlayerId` - Player identifier type (int)
|
||||
- `MCTSSimulationPolicy` - Enumeration of simulation strategies
|
||||
- `MCTSConfig` - Configuration structure for MCTS parameters
|
||||
|
||||
## Usage Pattern
|
||||
|
||||
To use this framework with your game:
|
||||
|
||||
1. **Implement the abstract interfaces** for your game:
|
||||
```cpp
|
||||
class MyGameAction : public MCTSAction { /* ... */ };
|
||||
class MyGameState : public MCTSGameState { /* ... */ };
|
||||
class MyGameEngine : public MCTSGameEngine { /* ... */ };
|
||||
```
|
||||
|
||||
2. **Create and configure the AI**:
|
||||
```cpp
|
||||
MCTSConfig config;
|
||||
config.explorationConstant = 1.414;
|
||||
config.maxSimulationDepth = 100;
|
||||
AbstractMCTSAI ai(playerId, config);
|
||||
```
|
||||
|
||||
3. **Run the search**:
|
||||
```cpp
|
||||
auto actions = engine.getLegalActions(currentState);
|
||||
auto result = ai.Search(engine, currentState, actions, timeLimit);
|
||||
auto bestAction = actions[result.bestActionIndex];
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
The framework includes comprehensive tests using a Tic-Tac-Toe implementation:
|
||||
- `MockTicTacToe.hpp` - Example implementation of all abstract interfaces
|
||||
- `AbstractMCTSAI_test.cpp` - Unit tests for the core algorithm
|
||||
- `MCTSIntegration_test.cpp` - Integration tests with complete games
|
||||
- `MCTSNode_test.cpp` - Tests for the node data structure
|
||||
|
||||
This demonstrates how to implement the interfaces and validates that the MCTS algorithm works correctly with any turn-based game.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,106 +0,0 @@
|
||||
//
|
||||
// Abstract MCTS AI implementation - game agnostic
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_ABSTRACT_MCTSAI_HPP
|
||||
#define EAGLE0_ABSTRACT_MCTSAI_HPP
|
||||
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "MCTSAction.hpp"
|
||||
#include "MCTSGameEngine.hpp"
|
||||
#include "MCTSGameState.hpp"
|
||||
#include "MCTSNode.hpp"
|
||||
#include "MCTSTypes.hpp"
|
||||
|
||||
namespace shardok {
|
||||
namespace mcts {
|
||||
|
||||
class AbstractMCTSAI {
|
||||
public:
|
||||
// Search result structure
|
||||
struct SearchResult {
|
||||
size_t bestActionIndex = 0;
|
||||
double bestScore = 0.0;
|
||||
int searchDepth = 0;
|
||||
int nodesEvaluated = 0;
|
||||
std::chrono::milliseconds searchTime{0};
|
||||
bool foundWinningMove = false;
|
||||
};
|
||||
|
||||
explicit AbstractMCTSAI(MCTSPlayerId playerId, MCTSConfig config = MCTSConfig{});
|
||||
|
||||
// Main search interface
|
||||
[[nodiscard]] auto Search(
|
||||
const MCTSGameEngine& engine,
|
||||
const MCTSGameState& initialState,
|
||||
std::chrono::milliseconds timeLimit) const -> SearchResult;
|
||||
|
||||
// Configuration
|
||||
[[nodiscard]] auto GetConfig() const -> const MCTSConfig& { return config_; }
|
||||
void SetConfig(const MCTSConfig& newConfig) { config_ = newConfig; }
|
||||
|
||||
[[nodiscard]] auto FindNodeAtDepthWithHash(
|
||||
const MCTSNode* root,
|
||||
int maxDepth,
|
||||
uint64_t targetHash) -> const MCTSNode*;
|
||||
|
||||
private:
|
||||
MCTSPlayerId playerId_;
|
||||
MCTSConfig config_;
|
||||
|
||||
// Transposition table: maps state hash -> minimum depth at which state was reached
|
||||
// Used to detect and penalize longer paths to the same game state
|
||||
// Cleared at the start of each Search() call
|
||||
mutable std::unordered_map<uint64_t, int> transpositionTable_;
|
||||
|
||||
// Core MCTS algorithm
|
||||
[[nodiscard]] auto BuildMCTSTree(
|
||||
const MCTSGameEngine& engine,
|
||||
const MCTSGameState& initialState,
|
||||
std::chrono::steady_clock::time_point deadline) const -> std::unique_ptr<MCTSNode>;
|
||||
|
||||
// MCTS phases
|
||||
[[nodiscard]] auto MCTSSelection(MCTSNode* root) const -> MCTSNode*;
|
||||
|
||||
[[nodiscard]] auto MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine) const
|
||||
-> MCTSNode*;
|
||||
|
||||
[[nodiscard]] auto MCTSSimulation(
|
||||
const MCTSGameEngine& engine,
|
||||
const MCTSGameState& state,
|
||||
MCTSPlayerId startingPlayer,
|
||||
int startingPlayerFlips = 0) const -> double;
|
||||
|
||||
auto MCTSBackpropagation(MCTSNode* node, double reward, MCTSBackpropagationPolicy policy) const
|
||||
-> void;
|
||||
|
||||
// Helper functions
|
||||
[[nodiscard]] auto SelectSimulationAction(
|
||||
const MCTSGameEngine& engine,
|
||||
const MCTSGameState& state,
|
||||
const std::vector<std::unique_ptr<MCTSAction>>& actions,
|
||||
bool isMaximizing) const -> size_t;
|
||||
|
||||
// Logging
|
||||
static auto LogSearchResults(
|
||||
const MCTSNode* rootNode,
|
||||
const MCTSNode* bestChild,
|
||||
const SearchResult& result) -> void;
|
||||
|
||||
// Debug tree dumping
|
||||
static auto DumpTreeToFile(const MCTSNode* root, const std::string& filepath) -> void;
|
||||
|
||||
private:
|
||||
static auto
|
||||
DumpNodeRecursive(const MCTSNode* node, std::ostream& out, int indentLevel, bool isLastChild)
|
||||
-> void;
|
||||
};
|
||||
|
||||
} // namespace mcts
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_ABSTRACT_MCTSAI_HPP
|
||||
@@ -1,94 +0,0 @@
|
||||
load("//tools:copts.bzl", "COPTS")
|
||||
|
||||
cc_library(
|
||||
name = "mcts_types",
|
||||
hdrs = ["MCTSTypes.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "mcts_action",
|
||||
hdrs = ["MCTSAction.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "mcts_game_state",
|
||||
hdrs = ["MCTSGameState.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":mcts_types",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "mcts_game_engine",
|
||||
srcs = ["MCTSGameEngine.cpp"],
|
||||
hdrs = ["MCTSGameEngine.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":mcts_action",
|
||||
":mcts_game_state",
|
||||
":mcts_types",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "mcts_node",
|
||||
hdrs = ["MCTSNode.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai:__pkg__",
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":mcts_action",
|
||||
":mcts_game_state",
|
||||
":mcts_types",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "abstract_mcts_ai",
|
||||
srcs = ["AbstractMCTSAI.cpp"],
|
||||
hdrs = ["AbstractMCTSAI.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = [
|
||||
"//src/main/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/common/mcts:__subpackages__",
|
||||
"//src/test/cpp/net/eagle0/shardok/ai/mcts:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":mcts_action",
|
||||
":mcts_game_engine",
|
||||
":mcts_game_state",
|
||||
":mcts_node",
|
||||
":mcts_types",
|
||||
"//src/main/cpp/net/eagle0/common/mcts/util:tree_indent_util",
|
||||
],
|
||||
)
|
||||
|
||||
# Individual targets are exposed above - no need for a catch-all target
|
||||
# Each component should be imported explicitly by its consumers
|
||||
@@ -1,40 +0,0 @@
|
||||
//
|
||||
// Abstract action interface for MCTS
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_MCTS_ACTION_HPP
|
||||
#define EAGLE0_MCTS_ACTION_HPP
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace shardok {
|
||||
namespace mcts {
|
||||
|
||||
// Abstract interface for game actions
|
||||
class MCTSAction {
|
||||
public:
|
||||
virtual ~MCTSAction() = default;
|
||||
|
||||
// Get a unique index for this action (used for command indexing)
|
||||
[[nodiscard]] virtual size_t getIndex() const = 0;
|
||||
|
||||
// Get a human-readable description for debugging/logging
|
||||
[[nodiscard]] virtual std::string getDescription() const = 0;
|
||||
|
||||
// Create a deep copy of this action
|
||||
[[nodiscard]] virtual std::unique_ptr<MCTSAction> clone() const = 0;
|
||||
|
||||
// Check if two actions are equivalent
|
||||
[[nodiscard]] virtual bool equals(const MCTSAction& other) const = 0;
|
||||
|
||||
// Check if this action requires a chance node (binary success/failure outcome)
|
||||
// Examples: START_FIRE, RAISE_DEAD, EXTINGUISH_FIRE
|
||||
// If true, the game engine should provide outcome probabilities
|
||||
[[nodiscard]] virtual bool requiresChanceNode() const = 0;
|
||||
};
|
||||
|
||||
} // namespace mcts
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_MCTS_ACTION_HPP
|
||||
@@ -1,148 +0,0 @@
|
||||
//
|
||||
// Default implementations for MCTSGameEngine
|
||||
//
|
||||
|
||||
#include "MCTSGameEngine.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
#include <random>
|
||||
#include <vector>
|
||||
|
||||
#include "MCTSTypes.hpp" // For MCTSInternalError
|
||||
|
||||
namespace shardok {
|
||||
namespace mcts {
|
||||
|
||||
double MCTSGameEngine::simulateRandomPlayout(
|
||||
const MCTSGameState& state,
|
||||
MCTSPlayerId playerId,
|
||||
int maxDepth,
|
||||
MCTSSimulationPolicy policy) const {
|
||||
// Clone state once and mutate it throughout simulation for efficiency
|
||||
auto currentState = state.clone();
|
||||
int depth = 0;
|
||||
|
||||
// Use thread-local random generator for thread safety
|
||||
static thread_local std::mt19937 gen(std::random_device{}());
|
||||
|
||||
// Simulate until terminal or max depth
|
||||
while (!currentState->isTerminal() && depth < maxDepth) {
|
||||
auto actions = getLegalActions(*currentState, playerId, 0, 0);
|
||||
if (actions.empty()) { break; }
|
||||
|
||||
size_t selectedIndex = 0;
|
||||
|
||||
// Select action based on policy
|
||||
switch (policy) {
|
||||
case MCTSSimulationPolicy::RANDOM: {
|
||||
std::uniform_int_distribution<> dis(0, actions.size() - 1);
|
||||
selectedIndex = dis(gen);
|
||||
break;
|
||||
}
|
||||
|
||||
case MCTSSimulationPolicy::FILTERED_RANDOM: {
|
||||
auto filteredIndices = filterActions(actions, *currentState);
|
||||
if (!filteredIndices.empty()) {
|
||||
std::uniform_int_distribution<> dis(0, filteredIndices.size() - 1);
|
||||
selectedIndex = filteredIndices[dis(gen)];
|
||||
} else {
|
||||
// Fall back to random if no actions pass filter
|
||||
std::uniform_int_distribution<> dis(0, actions.size() - 1);
|
||||
selectedIndex = dis(gen);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case MCTSSimulationPolicy::BEST_IMMEDIATE: {
|
||||
double bestScore = -std::numeric_limits<double>::infinity();
|
||||
for (size_t i = 0; i < actions.size(); ++i) {
|
||||
double score = getActionScore(
|
||||
*currentState,
|
||||
*actions[i],
|
||||
currentState->currentPlayerId());
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
selectedIndex = i;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case MCTSSimulationPolicy::WEIGHTED_BEST_IMMEDIATE: {
|
||||
// Score all actions and weight by ranking
|
||||
std::vector<std::pair<size_t, double>> scores;
|
||||
scores.reserve(actions.size());
|
||||
|
||||
for (size_t i = 0; i < actions.size(); ++i) {
|
||||
double score = getActionScore(
|
||||
*currentState,
|
||||
*actions[i],
|
||||
currentState->currentPlayerId());
|
||||
scores.emplace_back(i, score);
|
||||
}
|
||||
|
||||
// Sort by score (descending)
|
||||
std::sort(scores.begin(), scores.end(), [](const auto& a, const auto& b) {
|
||||
return a.second > b.second;
|
||||
});
|
||||
|
||||
// Create weights based on ranking (1/rank)
|
||||
std::vector<double> weights;
|
||||
weights.reserve(scores.size());
|
||||
for (size_t i = 0; i < scores.size(); ++i) { weights.push_back(1.0 / (i + 1.0)); }
|
||||
|
||||
// Select based on weights
|
||||
std::discrete_distribution<> dis(weights.begin(), weights.end());
|
||||
selectedIndex = scores[dis(gen)].first;
|
||||
break;
|
||||
}
|
||||
|
||||
case MCTSSimulationPolicy::WEIGHTED_HEURISTIC: {
|
||||
// Get heuristic weights (fast O(1) per action)
|
||||
const auto weights = getActionWeights(actions, *currentState);
|
||||
|
||||
// Filter out zero-weight actions
|
||||
std::vector<size_t> validIndices;
|
||||
std::vector<double> validWeights;
|
||||
validIndices.reserve(actions.size());
|
||||
validWeights.reserve(actions.size());
|
||||
|
||||
for (size_t i = 0; i < weights.size() && i < actions.size(); ++i) {
|
||||
if (weights[i] > 0.0) {
|
||||
validIndices.push_back(i);
|
||||
validWeights.push_back(weights[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// If all actions filtered out, this is a bug in the weighting logic
|
||||
if (validWeights.empty()) {
|
||||
throw MCTSInternalError(
|
||||
"MCTS simulation (playout): All actions have zero weight in "
|
||||
"WEIGHTED_HEURISTIC policy (action count: " +
|
||||
std::to_string(actions.size()) +
|
||||
") - this indicates incorrect weighting");
|
||||
}
|
||||
|
||||
// Select based on heuristic weights
|
||||
std::discrete_distribution<> dis(validWeights.begin(), validWeights.end());
|
||||
selectedIndex = validIndices[dis(gen)];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply selected action using mutable version for efficiency
|
||||
applyActionMutable(currentState, *actions[selectedIndex]);
|
||||
if (!currentState) {
|
||||
break; // Failed to apply action
|
||||
}
|
||||
|
||||
depth++;
|
||||
}
|
||||
|
||||
// Return evaluation from original player's perspective
|
||||
return evaluateState(*currentState, playerId);
|
||||
}
|
||||
|
||||
} // namespace mcts
|
||||
} // namespace shardok
|
||||
@@ -1,161 +0,0 @@
|
||||
//
|
||||
// Abstract game engine interface for MCTS
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_MCTS_GAME_ENGINE_HPP
|
||||
#define EAGLE0_MCTS_GAME_ENGINE_HPP
|
||||
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "MCTSAction.hpp"
|
||||
#include "MCTSGameState.hpp"
|
||||
#include "MCTSTypes.hpp"
|
||||
|
||||
namespace shardok {
|
||||
namespace mcts {
|
||||
|
||||
// Information about chance outcomes (supports both binary and multi-outcome)
|
||||
struct ChanceOutcomeInfo {
|
||||
std::vector<double> probabilities; // Probability of each outcome (must sum to 1.0)
|
||||
std::vector<double> rolls; // Roll values for each outcome
|
||||
|
||||
// Factory for binary success/failure outcomes (e.g., START_FIRE)
|
||||
[[nodiscard]] static ChanceOutcomeInfo binary(double successProbability) {
|
||||
// -100: triggers open-ended low sequence, succeeds against any threshold
|
||||
// 150: triggers open-ended high sequence, fails against any threshold
|
||||
return {{successProbability, 1.0 - successProbability}, {-100.0, 150.0}};
|
||||
}
|
||||
|
||||
// Factory for multi-outcome with fixed seeds (e.g., END_TURN)
|
||||
// Uses uniformly distributed roll values to sample different random outcomes
|
||||
[[nodiscard]] static ChanceOutcomeInfo multiOutcome(int numOutcomes) {
|
||||
std::vector<double> probs(numOutcomes, 1.0 / numOutcomes);
|
||||
std::vector<double> rollValues;
|
||||
rollValues.reserve(numOutcomes);
|
||||
// Spread rolls across the percentile range: 10, 30, 50, 70, 90 for 5 outcomes
|
||||
for (int i = 0; i < numOutcomes; ++i) {
|
||||
rollValues.push_back(10.0 + (80.0 * i) / (numOutcomes - 1));
|
||||
}
|
||||
return {probs, rollValues};
|
||||
}
|
||||
|
||||
[[nodiscard]] const std::vector<double>& getRepresentativeRolls() const { return rolls; }
|
||||
|
||||
[[nodiscard]] const std::vector<double>& getProbabilities() const { return probabilities; }
|
||||
};
|
||||
|
||||
// Backward compatibility alias
|
||||
using BinaryOutcomeInfo = ChanceOutcomeInfo;
|
||||
|
||||
// Abstract interface for game engines
|
||||
class MCTSGameEngine {
|
||||
public:
|
||||
virtual ~MCTSGameEngine() = default;
|
||||
|
||||
// Apply an action to a state and return the resulting state
|
||||
// If deterministicRoll is provided (0.0-100.0), use that for any random outcomes
|
||||
[[nodiscard]] virtual std::unique_ptr<MCTSGameState> applyAction(
|
||||
const MCTSGameState& state,
|
||||
const MCTSAction& action,
|
||||
double deterministicRoll = -1.0) const = 0;
|
||||
|
||||
// Apply an action to a mutable state in-place (for efficient simulation)
|
||||
// Default: clone, apply, and move the result back
|
||||
// Override this for better performance
|
||||
virtual void applyActionMutable(std::unique_ptr<MCTSGameState>& state, const MCTSAction& action)
|
||||
const {
|
||||
state = applyAction(*state, action);
|
||||
}
|
||||
|
||||
// Get all legal actions for the current state with player flip tracking
|
||||
// Default implementation ignores flip tracking and calls base version
|
||||
[[nodiscard]] virtual std::vector<std::unique_ptr<MCTSAction>> getLegalActions(
|
||||
const MCTSGameState& state,
|
||||
MCTSPlayerId /*rootPlayerId*/,
|
||||
int /*currentPlayerFlips*/,
|
||||
int /*maxPlayerFlips*/) const = 0;
|
||||
|
||||
// Check if a state is terminal
|
||||
[[nodiscard]] virtual bool isTerminal(const MCTSGameState& state) const = 0;
|
||||
|
||||
// Evaluate a state from the perspective of a player
|
||||
[[nodiscard]] virtual double evaluateState(const MCTSGameState& state, MCTSPlayerId playerId)
|
||||
const = 0;
|
||||
|
||||
// Filter actions based on game-specific heuristics
|
||||
// Returns indices of actions to keep
|
||||
// Default: no filtering (return all indices)
|
||||
[[nodiscard]] virtual std::vector<size_t> filterActions(
|
||||
const std::vector<std::unique_ptr<MCTSAction>>& actions,
|
||||
const MCTSGameState& /*state*/) const {
|
||||
std::vector<size_t> indices;
|
||||
indices.reserve(actions.size());
|
||||
for (size_t i = 0; i < actions.size(); ++i) { indices.push_back(i); }
|
||||
return indices;
|
||||
}
|
||||
|
||||
// Get heuristic weights for actions (used by WEIGHTED_HEURISTIC simulation policy)
|
||||
// Returns weights corresponding to each action (same size as actions vector)
|
||||
// Weight of 0.0 = never select, higher = more likely to select
|
||||
// Default: uniform weights (all actions equally likely)
|
||||
[[nodiscard]] virtual std::vector<double> getActionWeights(
|
||||
const std::vector<std::unique_ptr<MCTSAction>>& actions,
|
||||
const MCTSGameState& /*state*/) const {
|
||||
// Default: uniform weights
|
||||
return std::vector<double>(actions.size(), 1.0);
|
||||
}
|
||||
|
||||
// Simulate a random playout from the given state
|
||||
// Default implementation uses policy to select actions
|
||||
[[nodiscard]] virtual double simulateRandomPlayout(
|
||||
const MCTSGameState& state,
|
||||
MCTSPlayerId playerId,
|
||||
int maxDepth,
|
||||
MCTSSimulationPolicy policy) const;
|
||||
|
||||
// Get the immediate score of applying an action
|
||||
// Default: apply the action and evaluate the resulting state
|
||||
[[nodiscard]] virtual double getActionScore(
|
||||
const MCTSGameState& state,
|
||||
const MCTSAction& action,
|
||||
MCTSPlayerId playerId) const {
|
||||
auto newState = applyAction(state, action);
|
||||
if (!newState) { return 0.0; }
|
||||
return evaluateState(*newState, playerId);
|
||||
}
|
||||
|
||||
// Check if we should stop searching (e.g., time limit, found winning move)
|
||||
[[nodiscard]] virtual bool shouldStopSearch(
|
||||
const MCTSGameState& /*state*/,
|
||||
int /*iterations*/,
|
||||
std::chrono::steady_clock::time_point /*startTime*/) const {
|
||||
// Default: no early stopping
|
||||
return false;
|
||||
}
|
||||
|
||||
// Map a filtered action index back to the original unfiltered index
|
||||
// This is needed when getLegalActions() applies filtering - the returned actions
|
||||
// may be a subset of all available actions, and this maps back to the original index.
|
||||
// Default implementation: no filtering, so filtered index = original index
|
||||
[[nodiscard]] virtual size_t mapFilteredIndexToOriginal(
|
||||
size_t filteredIndex,
|
||||
const MCTSGameState& state) const {
|
||||
// Default: no filtering, index stays the same
|
||||
(void)state; // Suppress unused parameter warning
|
||||
return filteredIndex;
|
||||
}
|
||||
|
||||
// Get binary outcome information for an action that requires a chance node
|
||||
// Only called for actions where action.requiresChanceNode() returns true
|
||||
// Returns success probability for binary success/failure actions
|
||||
[[nodiscard]] virtual BinaryOutcomeInfo getBinaryOutcomeInfo(
|
||||
const MCTSGameState& state,
|
||||
const MCTSAction& action) const = 0;
|
||||
};
|
||||
|
||||
} // namespace mcts
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_MCTS_GAME_ENGINE_HPP
|
||||
@@ -1,50 +0,0 @@
|
||||
//
|
||||
// Abstract game state interface for MCTS
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_MCTS_GAME_STATE_HPP
|
||||
#define EAGLE0_MCTS_GAME_STATE_HPP
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "MCTSTypes.hpp"
|
||||
|
||||
namespace shardok {
|
||||
namespace mcts {
|
||||
|
||||
// Abstract interface for game states
|
||||
class MCTSGameState {
|
||||
public:
|
||||
virtual ~MCTSGameState() = default;
|
||||
|
||||
// Compute hash for transposition table
|
||||
[[nodiscard]] virtual uint64_t hash() const = 0;
|
||||
|
||||
// Evaluate the state from the perspective of the given player
|
||||
[[nodiscard]] virtual double score(MCTSPlayerId playerId) const = 0;
|
||||
|
||||
// Get the player whose turn it is
|
||||
[[nodiscard]] virtual MCTSPlayerId currentPlayerId() const = 0;
|
||||
|
||||
// Check if the game has ended
|
||||
[[nodiscard]] virtual bool isTerminal() const = 0;
|
||||
|
||||
// Create a deep copy of the state
|
||||
[[nodiscard]] virtual std::unique_ptr<MCTSGameState> clone() const = 0;
|
||||
|
||||
// Check if two states are equivalent
|
||||
[[nodiscard]] virtual bool equals(const MCTSGameState& other) const = 0;
|
||||
|
||||
// Get winner if terminal, or -1 if not terminal or draw
|
||||
[[nodiscard]] virtual MCTSPlayerId getWinner() const = 0;
|
||||
|
||||
// Optional: Get a string representation for debugging
|
||||
[[nodiscard]] virtual std::string toString() const { return "MCTSGameState"; }
|
||||
};
|
||||
|
||||
} // namespace mcts
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_MCTS_GAME_STATE_HPP
|
||||
@@ -1,277 +0,0 @@
|
||||
//
|
||||
// Abstract MCTS Node structure for game-agnostic implementation
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_ABSTRACT_MCTSNODE_HPP
|
||||
#define EAGLE0_ABSTRACT_MCTSNODE_HPP
|
||||
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "MCTSAction.hpp"
|
||||
#include "MCTSGameState.hpp"
|
||||
#include "MCTSTypes.hpp"
|
||||
|
||||
namespace shardok {
|
||||
namespace mcts {
|
||||
|
||||
// Node type for MCTS tree
|
||||
enum class NodeType {
|
||||
DECISION, // Player chooses an action (standard MCTS node)
|
||||
CHANCE // Nature determines outcome (for probabilistic actions)
|
||||
};
|
||||
|
||||
// Abstract MCTS Node structure
|
||||
struct MCTSNode {
|
||||
// Node type
|
||||
NodeType nodeType = NodeType::DECISION;
|
||||
// Action information
|
||||
std::unique_ptr<MCTSAction> action; // The action that led to this node (null for root)
|
||||
size_t actionIndex = SIZE_MAX; // Index in the original actions array (SIZE_MAX for root)
|
||||
|
||||
// Score information
|
||||
double immediateScore = 0.0;
|
||||
double lookaheadScore = 0.0;
|
||||
|
||||
// Game state after this action
|
||||
std::unique_ptr<MCTSGameState> gameState;
|
||||
|
||||
// MCTS statistics
|
||||
int visitCount = 0;
|
||||
double totalReward = 0.0;
|
||||
double averageReward = 0.0;
|
||||
mutable double ucb1Value = 0.0;
|
||||
double actionWeight = 1.0; // Prior probability/weight for this action (from heuristics)
|
||||
|
||||
// Tree structure
|
||||
std::vector<std::unique_ptr<MCTSNode>> children;
|
||||
size_t nextUntriedActionIndex = 0; // Next action to expand
|
||||
size_t totalActions = 0; // Total number of available actions
|
||||
MCTSNode* parent = nullptr;
|
||||
|
||||
// Chance node specific fields (only used when nodeType == CHANCE)
|
||||
std::vector<double> outcomeProbabilities; // Probability of each outcome
|
||||
std::vector<double> outcomeRolls; // Representative roll for each outcome
|
||||
|
||||
// Game context
|
||||
MCTSPlayerId playerId;
|
||||
int depth = 0;
|
||||
bool isTerminal = false;
|
||||
int playerFlips = 0; // Number of times the active player has changed from root player
|
||||
bool isMaximizingPlayer = true; // True if this node is maximizing for root player
|
||||
|
||||
// Transposition detection
|
||||
uint64_t stateHash = 0;
|
||||
bool isRedundant = false; // True if this node represents a duplicate state
|
||||
|
||||
// Constructor for root node
|
||||
MCTSNode(std::unique_ptr<MCTSGameState> state, MCTSPlayerId pid, int d)
|
||||
: gameState(std::move(state)),
|
||||
playerId(pid),
|
||||
depth(d),
|
||||
playerFlips(0),
|
||||
isMaximizingPlayer(true) {
|
||||
if (gameState) {
|
||||
stateHash = gameState->hash();
|
||||
isTerminal = gameState->isTerminal();
|
||||
}
|
||||
}
|
||||
|
||||
// Constructor for child node
|
||||
MCTSNode(
|
||||
std::unique_ptr<MCTSAction> act,
|
||||
std::unique_ptr<MCTSGameState> state,
|
||||
MCTSPlayerId pid,
|
||||
int d,
|
||||
size_t actIdx = SIZE_MAX,
|
||||
int flips = 0,
|
||||
bool isMaximizing = true,
|
||||
double weight = 1.0)
|
||||
: action(std::move(act)),
|
||||
actionIndex(actIdx),
|
||||
gameState(std::move(state)),
|
||||
actionWeight(weight),
|
||||
playerId(pid),
|
||||
depth(d),
|
||||
playerFlips(flips),
|
||||
isMaximizingPlayer(isMaximizing) {
|
||||
if (gameState) {
|
||||
stateHash = gameState->hash();
|
||||
isTerminal = gameState->isTerminal();
|
||||
}
|
||||
}
|
||||
|
||||
// Iterative destructor to avoid stack overflow with deep trees
|
||||
~MCTSNode() {
|
||||
std::vector<std::unique_ptr<MCTSNode>> nodesToDestroy;
|
||||
nodesToDestroy.swap(children);
|
||||
|
||||
while (!nodesToDestroy.empty()) {
|
||||
std::vector<std::unique_ptr<MCTSNode>> currentBatch;
|
||||
currentBatch.swap(nodesToDestroy);
|
||||
|
||||
for (const auto& node : currentBatch) {
|
||||
if (node && !node->children.empty()) {
|
||||
for (auto& child : node->children) {
|
||||
nodesToDestroy.push_back(std::move(child));
|
||||
}
|
||||
node->children.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate UCB1 value for this node from parent's perspective
|
||||
// Uses prior-weighted formula similar to AlphaGo:
|
||||
// UCB = Q + c * P * sqrt(N_parent) / (1 + N_child)
|
||||
// Where P is the action weight (prior probability from heuristics)
|
||||
[[nodiscard]] double CalculateUCB1(
|
||||
const double explorationConstant,
|
||||
const int parentVisitCount,
|
||||
const bool parentIsMaximizing) const {
|
||||
// Exploitation: use lookahead score (minimax value)
|
||||
// For minimizing nodes, negate the score to prefer low child values
|
||||
const double exploitationValue = parentIsMaximizing ? lookaheadScore : -lookaheadScore;
|
||||
|
||||
// Exploration: prior-weighted formula (AlphaGo-style)
|
||||
// Actions with weight 0.0 (like FLEE_COMMAND) get no exploration bonus
|
||||
// Unvisited nodes get: c * weight * sqrt(N_parent)
|
||||
// This prevents bad actions from dominating exploration due to infinite UCB
|
||||
const double explorationValue = explorationConstant * actionWeight *
|
||||
std::sqrt(parentVisitCount) / (1.0 + visitCount);
|
||||
|
||||
return exploitationValue + explorationValue;
|
||||
}
|
||||
|
||||
// Check if this node can be expanded
|
||||
[[nodiscard]] bool CanExpand() const { return nextUntriedActionIndex < totalActions; }
|
||||
|
||||
// Check if this is a chance node
|
||||
[[nodiscard]] bool IsChanceNode() const { return nodeType == NodeType::CHANCE; }
|
||||
|
||||
// Check if this is a decision node
|
||||
[[nodiscard]] bool IsDecisionNode() const { return nodeType == NodeType::DECISION; }
|
||||
|
||||
// Get best child from chance node (probability-weighted selection)
|
||||
// For chance nodes, we want to explore outcomes proportionally to their probability
|
||||
[[nodiscard]] MCTSNode* GetBestChanceChild() const {
|
||||
if (children.empty() || !IsChanceNode()) return nullptr;
|
||||
|
||||
// Find the outcome that is most under-explored relative to its probability
|
||||
// Expected visits for outcome i: total_visits * probability[i]
|
||||
// Actual visits: child[i]->visitCount
|
||||
// Deficit: expected - actual
|
||||
size_t bestIndex = 0;
|
||||
double bestDeficit = -std::numeric_limits<double>::max();
|
||||
|
||||
for (size_t i = 0; i < children.size(); i++) {
|
||||
if (!children[i] || children[i]->isRedundant) continue;
|
||||
|
||||
const double expectedVisits = visitCount * outcomeProbabilities[i];
|
||||
const double actualVisits = static_cast<double>(children[i]->visitCount);
|
||||
const double deficit = expectedVisits - actualVisits;
|
||||
|
||||
if (deficit > bestDeficit) {
|
||||
bestDeficit = deficit;
|
||||
bestIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
return children[bestIndex].get();
|
||||
}
|
||||
|
||||
// Get best child based on UCB1
|
||||
[[nodiscard]] MCTSNode* GetBestChild(const double explorationConstant) const {
|
||||
if (children.empty()) return nullptr;
|
||||
|
||||
MCTSNode* bestChild = nullptr;
|
||||
double bestValue = -std::numeric_limits<double>::max();
|
||||
|
||||
for (auto& child : children) {
|
||||
// Skip redundant nodes
|
||||
if (child->isRedundant) continue;
|
||||
|
||||
// Calculate UCB1 value using the helper function
|
||||
const double value =
|
||||
child->CalculateUCB1(explorationConstant, visitCount, isMaximizingPlayer);
|
||||
|
||||
// Debug logging for UCB selection
|
||||
static bool enableUCBDebug = false;
|
||||
if (enableUCBDebug && child->visitCount > 0) {
|
||||
const double exploitationValue =
|
||||
isMaximizingPlayer ? child->lookaheadScore : -child->lookaheadScore;
|
||||
const double explorationValue =
|
||||
explorationConstant * std::sqrt(std::log(visitCount) / child->visitCount);
|
||||
printf(" UCB: %s lookahead=%.2f expl=%.2f (+%.2f) = %.2f [%s]\n",
|
||||
isMaximizingPlayer ? "MAX" : "MIN",
|
||||
child->lookaheadScore,
|
||||
exploitationValue,
|
||||
explorationValue,
|
||||
value,
|
||||
child->action ? child->action->getDescription().c_str() : "root");
|
||||
}
|
||||
|
||||
if (value > bestValue) {
|
||||
bestValue = value;
|
||||
bestChild = child.get();
|
||||
}
|
||||
}
|
||||
|
||||
return bestChild;
|
||||
}
|
||||
|
||||
// Get best child based on visit count (for final selection)
|
||||
[[nodiscard]] MCTSNode* GetBestFinalChild() const {
|
||||
if (children.empty()) return nullptr;
|
||||
|
||||
MCTSNode* bestChild = nullptr;
|
||||
int bestVisits = 0;
|
||||
double bestScore = isMaximizingPlayer ? -std::numeric_limits<double>::max()
|
||||
: std::numeric_limits<double>::max();
|
||||
|
||||
for (const auto& child : children) {
|
||||
// Skip redundant nodes
|
||||
if (child->isRedundant) continue;
|
||||
|
||||
// Prefer most-visited node (robust child selection)
|
||||
if (child->visitCount > bestVisits) {
|
||||
bestVisits = child->visitCount;
|
||||
bestScore = child->lookaheadScore;
|
||||
bestChild = child.get();
|
||||
} else if (child->visitCount == bestVisits) {
|
||||
// Tie-break on lookahead score (minimax value, not poisoned average)
|
||||
// Maximizing: prefer higher score (better for root player)
|
||||
// Minimizing: prefer lower score (worse for root player)
|
||||
const bool shouldReplace = isMaximizingPlayer ? (child->lookaheadScore > bestScore)
|
||||
: (child->lookaheadScore < bestScore);
|
||||
if (shouldReplace) {
|
||||
bestScore = child->lookaheadScore;
|
||||
bestChild = child.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no child was visited, fall back to lookahead score
|
||||
if (!bestChild && !children.empty()) {
|
||||
for (const auto& child : children) {
|
||||
if (child->isRedundant) continue;
|
||||
|
||||
const bool shouldReplace = isMaximizingPlayer ? (child->lookaheadScore > bestScore)
|
||||
: (child->lookaheadScore < bestScore);
|
||||
if (shouldReplace) {
|
||||
bestScore = child->lookaheadScore;
|
||||
bestChild = child.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestChild;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace mcts
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_ABSTRACT_MCTSNODE_HPP
|
||||
@@ -1,60 +0,0 @@
|
||||
//
|
||||
// Core types for abstract MCTS implementation
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_MCTS_TYPES_HPP
|
||||
#define EAGLE0_MCTS_TYPES_HPP
|
||||
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
namespace shardok {
|
||||
namespace mcts {
|
||||
|
||||
// Exception thrown when MCTS encounters an internal error that indicates a bug
|
||||
class MCTSInternalError : public std::logic_error {
|
||||
public:
|
||||
explicit MCTSInternalError(const std::string& message) : std::logic_error(message) {}
|
||||
};
|
||||
|
||||
// Abstract player identifier type
|
||||
using MCTSPlayerId = int;
|
||||
|
||||
// Simulation policy for MCTS rollouts
|
||||
enum class MCTSSimulationPolicy {
|
||||
RANDOM, // Pure random selection
|
||||
FILTERED_RANDOM, // Random from filtered actions
|
||||
BEST_IMMEDIATE, // Choose best immediate score
|
||||
WEIGHTED_BEST_IMMEDIATE, // Random weighted by score ranking
|
||||
WEIGHTED_HEURISTIC // Random weighted by fast heuristics (no score evaluation)
|
||||
};
|
||||
|
||||
// Backpropagation policy for MCTS tree updates
|
||||
enum class MCTSBackpropagationPolicy {
|
||||
AVERAGING, // Traditional MCTS averaging (for stochastic/single-player games)
|
||||
MINIMAX // Minimax backup (for deterministic adversarial games)
|
||||
};
|
||||
|
||||
// Configuration for MCTS algorithm
|
||||
struct MCTSConfig {
|
||||
double explorationConstant = 1.414; // UCB1 constant (sqrt(2) by default)
|
||||
int maxSimulationDepth = 1000; // Maximum depth for rollout
|
||||
int maxTreeDepth = 2000; // Maximum tree depth to prevent stack overflow
|
||||
bool useMultithreading = true; // Enable parallel MCTS
|
||||
int numThreads = 16; // Number of threads for parallel MCTS
|
||||
MCTSSimulationPolicy simulationPolicy = MCTSSimulationPolicy::BEST_IMMEDIATE;
|
||||
MCTSBackpropagationPolicy backpropagationPolicy = MCTSBackpropagationPolicy::AVERAGING;
|
||||
int maxPlayerFlips = 0; // Maximum number of player changes for tree expansion
|
||||
// (0 = expand through current player's turn only,
|
||||
// 1 = expand through opponent's first response, etc.)
|
||||
int maxSimulationFlips = 0; // Maximum player flips for leaf evaluation
|
||||
// When evaluating a leaf at playerFlips < maxSimulationFlips,
|
||||
// simulate forward to this phase for fair comparison
|
||||
// (default 0 = evaluate leaves as-is, backward compatible)
|
||||
std::string debugDumpPath = ""; // If non-empty, dump MCTS tree to this file path
|
||||
};
|
||||
|
||||
} // namespace mcts
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_MCTS_TYPES_HPP
|
||||
@@ -1,8 +0,0 @@
|
||||
load("@rules_cc//cc:defs.bzl", "cc_library")
|
||||
|
||||
cc_library(
|
||||
name = "tree_indent_util",
|
||||
srcs = ["TreeIndentUtil.cpp"],
|
||||
hdrs = ["TreeIndentUtil.hpp"],
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
@@ -1,53 +0,0 @@
|
||||
//
|
||||
// Utility functions for processing tree indentation with UTF-8 box drawing characters
|
||||
//
|
||||
|
||||
#include "TreeIndentUtil.hpp"
|
||||
|
||||
namespace mcts::util {
|
||||
|
||||
namespace {
|
||||
// Box drawing characters for tree visualization
|
||||
constexpr const char* kBranch = "\xE2\x94\x9C"; // ├
|
||||
constexpr const char* kCorner = "\xE2\x94\x94"; // └
|
||||
constexpr const char* kVertical = "\xE2\x94\x82"; // │
|
||||
constexpr const char* kHorizontal = "\xE2\x94\x80"; // ─
|
||||
} // namespace
|
||||
|
||||
std::string BuildTreeIndent(int indentLevel, bool isLastChild) {
|
||||
std::string indent;
|
||||
|
||||
for (int i = 0; i < indentLevel; ++i) {
|
||||
if (i == indentLevel - 1) {
|
||||
indent += isLastChild ? kCorner : kBranch;
|
||||
indent += kHorizontal;
|
||||
indent += " ";
|
||||
} else {
|
||||
indent += " ";
|
||||
}
|
||||
}
|
||||
|
||||
return indent;
|
||||
}
|
||||
|
||||
std::string ConvertBranchToContinuation(const std::string& indent) {
|
||||
std::string result = indent;
|
||||
|
||||
const std::string replacement = std::string(kVertical) + " ";
|
||||
|
||||
// Replace ├ and └ with │
|
||||
size_t pos = 0;
|
||||
while ((pos = result.find(kBranch, pos)) != std::string::npos) {
|
||||
result.replace(pos, 3, replacement); // UTF-8 chars are 3 bytes
|
||||
pos += replacement.size();
|
||||
}
|
||||
pos = 0;
|
||||
while ((pos = result.find(kCorner, pos)) != std::string::npos) {
|
||||
result.replace(pos, 3, replacement);
|
||||
pos += replacement.size();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace mcts::util
|
||||
@@ -1,22 +0,0 @@
|
||||
//
|
||||
// Utility functions for processing tree indentation with UTF-8 box drawing characters
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_TREE_INDENT_UTIL_HPP
|
||||
#define EAGLE0_TREE_INDENT_UTIL_HPP
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace mcts::util {
|
||||
|
||||
// Builds tree indentation string for a node at a given depth
|
||||
// Returns string like " ├─ " or " └─ " with proper spacing
|
||||
std::string BuildTreeIndent(int indentLevel, bool isLastChild);
|
||||
|
||||
// Converts tree branch characters (├ and └) to continuation lines (│) for sub-content
|
||||
// This preserves the tree structure when displaying additional info below a node
|
||||
std::string ConvertBranchToContinuation(const std::string& indent);
|
||||
|
||||
} // namespace mcts::util
|
||||
|
||||
#endif // EAGLE0_TREE_INDENT_UTIL_HPP
|
||||
@@ -51,7 +51,8 @@ cc_binary(
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/common:byte_vector",
|
||||
"//src/main/cpp/net/eagle0/common:filesystem_utils",
|
||||
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
|
||||
"//src/main/cpp/net/eagle0/shardok/library/fb_helpers:flatbuffer_wrapper",
|
||||
"//src/main/flatbuffer/net/eagle0/shardok/storage:game_state_cc_fbs",
|
||||
"//src/main/protobuf/net/eagle0/common:shardok_internal_interface_cc_grpc",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
//
|
||||
|
||||
#include "src/main/cpp/net/eagle0/common/byte_vector.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/fb_helpers/FlatbufferWrapper.hpp"
|
||||
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state.hpp"
|
||||
#include "src/main/protobuf/net/eagle0/common/shardok_internal_interface.pb.h"
|
||||
#include "src/main/protobuf/net/eagle0/shardok/storage/game.pb.h"
|
||||
|
||||
using GameStateW = shardok::Wrapper<net::eagle0::shardok::storage::fb::GameState>;
|
||||
|
||||
auto main(int argc, char** argv) -> int {
|
||||
char* path = argv[1];
|
||||
|
||||
@@ -24,8 +27,8 @@ auto main(int argc, char** argv) -> int {
|
||||
printf("There are %d results\n", arCount);
|
||||
|
||||
for (int arIndex = 0; arIndex < arCount; arIndex++) {
|
||||
shardok::GameStateW gameState =
|
||||
shardok::GameStateW::FromByteString(game.action_result(arIndex).state_after_fb());
|
||||
GameStateW gameState =
|
||||
GameStateW::FromByteString(game.action_result(arIndex).state_after_fb());
|
||||
const auto* hexMap = gameState->hex_map();
|
||||
|
||||
for (int terrainIndex = 0; terrainIndex < hexMap->terrain()->size(); terrainIndex++) {
|
||||
|
||||
@@ -36,7 +36,7 @@ auto CalculateMap(
|
||||
.name = mapName,
|
||||
.positionsRequiringCrossing = {}};
|
||||
|
||||
for (unsigned int i = 0; i < hexMap->attacker_starting_positions()->size(); i++) {
|
||||
for (int i = 0; i < hexMap->attacker_starting_positions()->size(); i++) {
|
||||
const auto* positionList = hexMap->attacker_starting_positions()->Get(i);
|
||||
if (positionList->positions()->size() < 1) continue;
|
||||
if (positionList->positions()->size() != 10) {
|
||||
|
||||
@@ -5,9 +5,7 @@
|
||||
#ifndef EAGLE0_MAPINFOCALCULATOR_HPP
|
||||
#define EAGLE0_MAPINFOCALCULATOR_HPP
|
||||
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
//
|
||||
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
|
||||
#include "MapInfoCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
|
||||
@@ -53,7 +52,7 @@ auto main(const int argc, char** argv) -> int {
|
||||
outputStream << " \"positions\": {";
|
||||
|
||||
bool firstPosition = true;
|
||||
for (const auto& [position, count] : mapInfo.positionsRequiringCrossing) {
|
||||
for (const auto& kv : mapInfo.positionsRequiringCrossing) {
|
||||
if (firstPosition) {
|
||||
outputStream << endl;
|
||||
firstPosition = false;
|
||||
@@ -61,7 +60,7 @@ auto main(const int argc, char** argv) -> int {
|
||||
outputStream << "," << endl;
|
||||
}
|
||||
|
||||
outputStream << " \"" << position << "\": " << count;
|
||||
outputStream << " \"" << kv.first << "\": " << kv.second;
|
||||
}
|
||||
outputStream << endl << " }" << endl << " }";
|
||||
}
|
||||
|
||||
@@ -4,11 +4,7 @@
|
||||
|
||||
#include "AIAttackGroups.hpp"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <iterator>
|
||||
#include <ranges>
|
||||
#include <unordered_map>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
|
||||
|
||||
namespace shardok {
|
||||
@@ -16,9 +12,6 @@ namespace shardok {
|
||||
constexpr double kOverpowerRatio = 2.0;
|
||||
constexpr double kBraveWaterCostMultiplier = 1.2;
|
||||
|
||||
using std::pair;
|
||||
using std::shared_ptr;
|
||||
|
||||
DIST_T NormalizedCostWhenBraving(const DIST_T cost) {
|
||||
if (cost >= static_cast<double>(ActionPointDistances::IMPOSSIBLE) / kBraveWaterCostMultiplier)
|
||||
return ActionPointDistances::IMPOSSIBLE;
|
||||
@@ -36,13 +29,13 @@ struct TargetAndDistance {
|
||||
: target(t),
|
||||
attackLocations(al),
|
||||
targetPower(tp),
|
||||
distance(d) {}
|
||||
distance(d){};
|
||||
};
|
||||
|
||||
auto MinDistance(
|
||||
const Coords& start,
|
||||
const CoordsSet& destinations,
|
||||
const ActionPointDistances* apd) -> DIST_T {
|
||||
const std::shared_ptr<ActionPointDistances>& apd) -> DIST_T {
|
||||
DIST_T minDistance = ActionPointDistances::IMPOSSIBLE;
|
||||
|
||||
for (const Coords& dest : destinations) {
|
||||
@@ -57,8 +50,8 @@ auto MinDistance(
|
||||
auto MinDistanceIncludingBraving(
|
||||
const Coords& start,
|
||||
const CoordsSet& destinations,
|
||||
const ActionPointDistances* notBravingApd,
|
||||
const ActionPointDistances* bravingApd) {
|
||||
const std::shared_ptr<ActionPointDistances>& notBravingApd,
|
||||
const std::shared_ptr<ActionPointDistances>& bravingApd) {
|
||||
// First try to get there without braving
|
||||
if (const DIST_T notBravingDistance = MinDistance(start, destinations, notBravingApd);
|
||||
notBravingDistance < ActionPointDistances::IMPOSSIBLE) {
|
||||
@@ -75,45 +68,39 @@ auto MinDistanceIncludingBraving(
|
||||
auto EffectiveDistance(
|
||||
const Unit* unit,
|
||||
const HexMap* map,
|
||||
const AttackLocations& attackLocations,
|
||||
const MapId& mapId,
|
||||
const APDCache& apdCache,
|
||||
const BattalionTypeGetter& battalionTypeGetter,
|
||||
ActionPoints braveWaterCost) -> DIST_T {
|
||||
const AttackLocations& attackLocations,
|
||||
const SettingsGetter& settings,
|
||||
const int braveWaterCost) -> DIST_T {
|
||||
return EffectiveDistance(
|
||||
unit,
|
||||
map,
|
||||
attackLocations.LocationsWithEnemyInRange(unit),
|
||||
mapId,
|
||||
apdCache,
|
||||
battalionTypeGetter,
|
||||
attackLocations.LocationsWithEnemyInRange(unit),
|
||||
settings,
|
||||
braveWaterCost);
|
||||
}
|
||||
|
||||
auto EffectiveDistance(
|
||||
const Unit* unit,
|
||||
const HexMap* map,
|
||||
const CoordsSet& locations,
|
||||
const MapId& mapId,
|
||||
const APDCache& apdCache,
|
||||
const BattalionTypeGetter& battalionTypeGetter,
|
||||
ActionPoints braveWaterCost) -> DIST_T {
|
||||
const auto mapId = ActionPointDistancesCache::GetMapId(map);
|
||||
const auto& battType = battalionTypeGetter(unit->battalion().type());
|
||||
const auto* notBravingApd = apdCache->GetRaw(map, mapId, battType, false);
|
||||
const ActionPointDistances* bravingApd = nullptr;
|
||||
const CoordsSet& locations,
|
||||
const SettingsGetter& settings,
|
||||
const int braveWaterCost) -> DIST_T {
|
||||
const auto& battType = settings.GetBattalionType(unit->battalion().type());
|
||||
const auto& notBravingApd = apdCache->Get(map, mapId, battType, false);
|
||||
std::shared_ptr<ActionPointDistances> bravingApd = nullptr;
|
||||
if (battType->allowsBraveWater) {
|
||||
bravingApd = apdCache->GetRaw(map, mapId, battType, true, braveWaterCost);
|
||||
bravingApd = apdCache->Get(map, mapId, battType, true, braveWaterCost);
|
||||
}
|
||||
|
||||
return MinDistanceIncludingBraving(unit->location(), locations, notBravingApd, bravingApd);
|
||||
}
|
||||
|
||||
auto EffectiveDistance(
|
||||
const Unit* unit,
|
||||
const ActionPointDistances* notBravingApd,
|
||||
const ActionPointDistances* bravingApd,
|
||||
const CoordsSet& locations) -> DIST_T {
|
||||
return MinDistanceIncludingBraving(unit->location(), locations, notBravingApd, bravingApd);
|
||||
}
|
||||
|
||||
auto Power(const Unit* unit) -> double { return unit->battalion().size(); }
|
||||
|
||||
auto CoordsIndex(const Coords& coords, const int columnCount) {
|
||||
@@ -129,12 +116,12 @@ auto GenerateTargetPriorities(
|
||||
const vector<const Unit*>& remainingUnits,
|
||||
const APDCache& apdCache,
|
||||
const ALCache& alCache,
|
||||
const BattalionTypeGetter& battalionTypeGetter,
|
||||
ActionPoints braveWaterCost,
|
||||
const MapId& mapId,
|
||||
const SettingsGetter& settings,
|
||||
const bool isLateGame) -> vector<TargetPriorityList> {
|
||||
auto cc = map->column_count();
|
||||
|
||||
const auto mapId = ActionPointDistancesCache::GetMapId(map);
|
||||
const auto braveWaterCost = settings.Backing().brave_water_action_point_cost();
|
||||
|
||||
vector<TargetPriorityList> allTargetsUnitsAndDistances{};
|
||||
allTargetsUnitsAndDistances.reserve(remainingUnits.size());
|
||||
@@ -143,11 +130,11 @@ auto GenerateTargetPriorities(
|
||||
|
||||
vector<const Unit*> sortedAttackers = remainingUnits;
|
||||
// Handle stronger units first
|
||||
std::ranges::sort(sortedAttackers, [](const Unit* left, const Unit* right) {
|
||||
return Power(left) > Power(right);
|
||||
});
|
||||
std::sort(
|
||||
begin(sortedAttackers),
|
||||
end(sortedAttackers),
|
||||
[](const Unit* left, const Unit* right) { return Power(left) > Power(right); });
|
||||
|
||||
// APDCache now has built-in thread-local caching - no need for local apdByBattType map
|
||||
// For each unit, sort the targets by distance from the unit to an attack location for the
|
||||
// target
|
||||
for (const Unit* unit : sortedAttackers) {
|
||||
@@ -157,14 +144,6 @@ auto GenerateTargetPriorities(
|
||||
|
||||
vector<TargetAndDistance> targetsWithDistance;
|
||||
|
||||
// Get APDs directly from cache (now with built-in thread-local optimization)
|
||||
const auto& battType = battalionTypeGetter(unit->battalion().type());
|
||||
const auto* notBravingApd = apdCache->GetRaw(map, mapId, battType, false);
|
||||
const ActionPointDistances* bravingApd = nullptr;
|
||||
if (battType->allowsBraveWater) {
|
||||
bravingApd = apdCache->GetRaw(map, mapId, battType, true, braveWaterCost);
|
||||
}
|
||||
|
||||
for (const Coords& targetLocation : targets) {
|
||||
const auto coordsIndex = CoordsIndex(targetLocation, cc);
|
||||
const auto& occupant = occupants[coordsIndex];
|
||||
@@ -175,13 +154,18 @@ auto GenerateTargetPriorities(
|
||||
double occupantPower = Power(occupant);
|
||||
|
||||
if (unit->location().row() >= 0) {
|
||||
const auto& attackLocsForUnit = attackLocations.LocationsWithEnemyInRange(unit);
|
||||
auto distance =
|
||||
EffectiveDistance(unit, notBravingApd, bravingApd, attackLocsForUnit);
|
||||
auto distance = EffectiveDistance(
|
||||
unit,
|
||||
map,
|
||||
mapId,
|
||||
apdCache,
|
||||
attackLocations,
|
||||
settings,
|
||||
braveWaterCost);
|
||||
|
||||
targetsWithDistance.emplace_back(
|
||||
targetLocation,
|
||||
attackLocsForUnit,
|
||||
attackLocations.LocationsWithEnemyInRange(unit),
|
||||
occupantPower,
|
||||
distance);
|
||||
} else {
|
||||
@@ -195,8 +179,9 @@ auto GenerateTargetPriorities(
|
||||
}
|
||||
|
||||
// Sort by distance
|
||||
std::ranges::sort(
|
||||
targetsWithDistance,
|
||||
std::sort(
|
||||
begin(targetsWithDistance),
|
||||
end(targetsWithDistance),
|
||||
[&powerAttackingEachTarget,
|
||||
cc](const TargetAndDistance& left, const TargetAndDistance& right) {
|
||||
const auto leftIndex = CoordsIndex(left.target, cc);
|
||||
@@ -221,15 +206,11 @@ auto GenerateTargetPriorities(
|
||||
Power(unit);
|
||||
}
|
||||
|
||||
tpl.priorityOrder.reserve(targetsWithDistance.size());
|
||||
std::ranges::transform(
|
||||
targetsWithDistance,
|
||||
std::back_inserter(tpl.priorityOrder),
|
||||
[](const TargetAndDistance& tad) {
|
||||
return TargetAndAttackLocations{
|
||||
.target = tad.target,
|
||||
.attackLocations = tad.attackLocations};
|
||||
});
|
||||
tpl.priorityOrder = common::Map(targetsWithDistance, [](const TargetAndDistance& tad) {
|
||||
return TargetAndAttackLocations{
|
||||
.target = tad.target,
|
||||
.attackLocations = tad.attackLocations};
|
||||
});
|
||||
}
|
||||
|
||||
return allTargetsUnitsAndDistances;
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
#ifndef EAGLE0_AIATTACKGROUPS_HPP
|
||||
#define EAGLE0_AIATTACKGROUPS_HPP
|
||||
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
|
||||
#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/map/CoordsSet.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
|
||||
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state.hpp"
|
||||
#include "src/main/flatbuffer/net/eagle0/shardok/storage/player_info.hpp"
|
||||
#include "src/main/flatbuffer/net/eagle0/shardok/storage/unit.hpp"
|
||||
@@ -22,8 +22,6 @@ using Unit = net::eagle0::shardok::storage::fb::Unit;
|
||||
using net::eagle0::shardok::storage::fb::PlayerInfo;
|
||||
using std::vector;
|
||||
|
||||
using BattalionTypeGetter = std::function<BattalionTypeSPtr(BattalionTypeId)>;
|
||||
|
||||
struct TargetAndAttackLocations {
|
||||
Coords target;
|
||||
CoordsSet attackLocations;
|
||||
@@ -43,24 +41,20 @@ struct TargetPriorityList {
|
||||
auto EffectiveDistance(
|
||||
const Unit* unit,
|
||||
const HexMap* map,
|
||||
const AttackLocations& attackLocations,
|
||||
const MapId& mapId,
|
||||
const APDCache& apdCache,
|
||||
const BattalionTypeGetter& battalionTypeGetter,
|
||||
ActionPoints braveWaterCost) -> DIST_T;
|
||||
const AttackLocations& attackLocations,
|
||||
const SettingsGetter& settings,
|
||||
int braveWaterCost) -> DIST_T;
|
||||
|
||||
auto EffectiveDistance(
|
||||
const Unit* unit,
|
||||
const HexMap* map,
|
||||
const CoordsSet& locations,
|
||||
const MapId& mapId,
|
||||
const APDCache& apdCache,
|
||||
const BattalionTypeGetter& battalionTypeGetter,
|
||||
ActionPoints braveWaterCost) -> DIST_T;
|
||||
|
||||
auto EffectiveDistance(
|
||||
const Unit* unit,
|
||||
const ActionPointDistances* notBravingApd,
|
||||
const ActionPointDistances* bravingApd,
|
||||
const CoordsSet& locations) -> DIST_T;
|
||||
const CoordsSet& locations,
|
||||
const SettingsGetter& settings,
|
||||
int braveWaterCost) -> DIST_T;
|
||||
|
||||
// Chooses a list of targets in priority order for each unit.
|
||||
auto GenerateTargetPriorities(
|
||||
@@ -71,8 +65,8 @@ auto GenerateTargetPriorities(
|
||||
const vector<const Unit*>& remainingUnits,
|
||||
const APDCache& apdCache,
|
||||
const ALCache& alCache,
|
||||
const BattalionTypeGetter& battalionTypeGetter,
|
||||
ActionPoints braveWaterCost,
|
||||
const MapId& mapId,
|
||||
const SettingsGetter& settings,
|
||||
bool isLateGame = false) -> vector<TargetPriorityList>;
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
|
||||
#include "AIAttackerStrategySelector.hpp"
|
||||
|
||||
#include "AIAttackGroups.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIFleeDecisionCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreUtilities.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
|
||||
|
||||
@@ -13,23 +11,21 @@ namespace shardok {
|
||||
|
||||
using Unit = net::eagle0::shardok::storage::fb::Unit;
|
||||
|
||||
// Combat success threshold below which we should consider fleeing
|
||||
// This replaces the simple troop ratio check with sophisticated probability estimation
|
||||
constexpr double FLEE_CONSIDERATION_THRESHOLD = 0.25;
|
||||
constexpr double MAXIMUM_RATIO_FOR_ATTACKER_TO_FLEE = 0.50;
|
||||
|
||||
auto AIAttackerStrategySelector::BestAttackerStrategy(
|
||||
const PlayerId attackerPid,
|
||||
const GameStateW& gameState,
|
||||
const net::eagle0::shardok::storage::fb::GameState* gameState,
|
||||
const CoordsSet& criticalTileCoords,
|
||||
int maxRounds,
|
||||
const APDCache& apdCache,
|
||||
const ALCache& alCache,
|
||||
const BattalionTypeGetter& battalionTypeGetter,
|
||||
ActionPoints braveWaterCost,
|
||||
const SettingsGetter& settings,
|
||||
const AIWaterCrossingCommandChooser& waterCrossingCommandChooser,
|
||||
const CommandListSPtr& /*availableCommands*/) -> AIStrategy {
|
||||
const vector<CommandProto>& availableCommands) -> AIStrategy {
|
||||
uint32_t attackerUnitCount = 0;
|
||||
int defenderOccupiedCriticalTileCount = 0;
|
||||
int attackerTroops = 0;
|
||||
int defenderTroops = 0;
|
||||
bool canFlee = false;
|
||||
|
||||
vector<const Unit*> attackerUnits{};
|
||||
@@ -44,6 +40,8 @@ auto AIAttackerStrategySelector::BestAttackerStrategy(
|
||||
if (pi != nullptr) {
|
||||
if (pi->is_defender()) {
|
||||
if (unit->location().row() >= 0) {
|
||||
defenderTroops += unit->battalion().size();
|
||||
|
||||
if (criticalTileCoords.Contains(unit->location())) {
|
||||
++defenderOccupiedCriticalTileCount;
|
||||
}
|
||||
@@ -52,6 +50,7 @@ auto AIAttackerStrategySelector::BestAttackerStrategy(
|
||||
}
|
||||
} else if (unit->player_id() == attackerPid) {
|
||||
++attackerUnitCount;
|
||||
attackerTroops += unit->battalion().size();
|
||||
if (unit->can_flee()) canFlee = true;
|
||||
attackerUnits.push_back(unit);
|
||||
} else {
|
||||
@@ -61,19 +60,11 @@ auto AIAttackerStrategySelector::BestAttackerStrategy(
|
||||
}
|
||||
|
||||
AIStrategy chosenStrategy;
|
||||
|
||||
// Use sophisticated combat success estimation instead of simple troop ratio
|
||||
if (canFlee && AIFleeDecisionCalculator::ShouldConsiderFleeing(
|
||||
attackerPid,
|
||||
gameState,
|
||||
maxRounds,
|
||||
FLEE_CONSIDERATION_THRESHOLD)) {
|
||||
if (canFlee && attackerTroops < MAXIMUM_RATIO_FOR_ATTACKER_TO_FLEE * defenderTroops) {
|
||||
chosenStrategy = FleeStrategy;
|
||||
} else if (const CoordsSet startCrossingLocations =
|
||||
waterCrossingCommandChooser.StartCrossingFrom(
|
||||
battalionTypeGetter,
|
||||
gameState,
|
||||
criticalTileCoords);
|
||||
waterCrossingCommandChooser
|
||||
.StartCrossingFrom(settings, gameState, criticalTileCoords);
|
||||
!startCrossingLocations.empty()) {
|
||||
chosenStrategy = CrossRiversStrategy(startCrossingLocations);
|
||||
} else if (attackerUnitCount < criticalTileCoords.size()) {
|
||||
@@ -88,8 +79,8 @@ auto AIAttackerStrategySelector::BestAttackerStrategy(
|
||||
attackerUnits,
|
||||
apdCache,
|
||||
alCache,
|
||||
battalionTypeGetter,
|
||||
braveWaterCost));
|
||||
ActionPointDistancesCache::GetMapId(gameState->hex_map()),
|
||||
settings));
|
||||
}
|
||||
// If any critical tile is occupied by the defender, attack the castles.
|
||||
// Otherwise, try to hold the castles.
|
||||
@@ -105,8 +96,8 @@ auto AIAttackerStrategySelector::BestAttackerStrategy(
|
||||
attackerUnits,
|
||||
apdCache,
|
||||
alCache,
|
||||
battalionTypeGetter,
|
||||
braveWaterCost));
|
||||
ActionPointDistancesCache::GetMapId(gameState->hex_map()),
|
||||
settings));
|
||||
} else {
|
||||
chosenStrategy = HoldCastlesStrategy;
|
||||
}
|
||||
|
||||
@@ -6,29 +6,26 @@
|
||||
#define EAGLE0_AIATTACKERSTRATEGYSELECTOR_HPP
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AICommonTypes.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIWaterCrossingCommandChooser.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCommand.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
|
||||
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
using GameState = net::eagle0::shardok::storage::fb::GameState;
|
||||
|
||||
class AIAttackerStrategySelector {
|
||||
public:
|
||||
static auto BestAttackerStrategy(
|
||||
PlayerId attackerPid,
|
||||
const GameStateW& gameState,
|
||||
const GameState* gameState,
|
||||
const CoordsSet& criticalTileCoords,
|
||||
int maxRounds,
|
||||
const APDCache& apdCache,
|
||||
const ALCache& alCache,
|
||||
const BattalionTypeGetter& battalionTypeGetter,
|
||||
ActionPoints braveWaterCost,
|
||||
const SettingsGetter& settings,
|
||||
const AIWaterCrossingCommandChooser& waterCrossingCommandChooser,
|
||||
const CommandListSPtr& availableCommands) -> AIStrategy;
|
||||
const vector<CommandProto>& availableCommands) -> AIStrategy;
|
||||
};
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
@@ -1,560 +0,0 @@
|
||||
//
|
||||
// Command evaluator for AI lookahead search.
|
||||
// Extracted from AIScoreCalculator to separate concerns.
|
||||
//
|
||||
|
||||
#include "AICommandEvaluator.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <future>
|
||||
#include <limits>
|
||||
|
||||
#include "AICommandFilter.hpp"
|
||||
#include "TranspositionTable.hpp"
|
||||
#include "src/main/cpp/net/eagle0/common/SequenceRandomGenerator.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/util/HexCubeUtils.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
// No need to forward declare internal functions - use the public interface instead
|
||||
|
||||
// Helper constants and static variables
|
||||
static const std::vector<double> _averageSequence = {0.5};
|
||||
static const auto _averageGenerator = std::make_shared<SequenceRandomGenerator>(_averageSequence);
|
||||
|
||||
#define MULTITHREAD true
|
||||
#define LOGGING_ 0
|
||||
|
||||
// Helper function to determine if a command type is deterministic
|
||||
static auto IsDeterministic(const CommandType type) -> bool {
|
||||
switch (type) {
|
||||
case net::eagle0::shardok::common::MOVE_COMMAND:
|
||||
case net::eagle0::shardok::common::CONTROL_COMMAND:
|
||||
case net::eagle0::shardok::common::METEOR_START_COMMAND:
|
||||
case net::eagle0::shardok::common::METEOR_TARGET_COMMAND:
|
||||
case net::eagle0::shardok::common::METEOR_CANCEL_COMMAND:
|
||||
case net::eagle0::shardok::common::END_TURN_COMMAND:
|
||||
case net::eagle0::shardok::common::PLACE_UNIT_COMMAND:
|
||||
case net::eagle0::shardok::common::PLACE_HIDDEN_UNIT_COMMAND:
|
||||
case net::eagle0::shardok::common::UNIT_STOP_COMMAND:
|
||||
case net::eagle0::shardok::common::UNIT_REST_COMMAND:
|
||||
case net::eagle0::shardok::common::FLEE_COMMAND:
|
||||
case net::eagle0::shardok::common::REINFORCE_COMMAND:
|
||||
case net::eagle0::shardok::common::RETREAT_COMMAND:
|
||||
case net::eagle0::shardok::common::END_PLAYER_SETUP_COMMAND:
|
||||
case net::eagle0::shardok::common::HIDE_COMMAND:
|
||||
case net::eagle0::shardok::common::FORTIFY_COMMAND:
|
||||
case net::eagle0::shardok::common::BECOME_OUTLAW_COMMAND:
|
||||
case net::eagle0::shardok::common::HOLY_WAVE_COMMAND:
|
||||
case net::eagle0::shardok::common::REPAIR_COMMAND: return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to sort commands by score
|
||||
static auto CommandSorter(
|
||||
const AICommandEvaluator::IndexAndScore& l,
|
||||
const AICommandEvaluator::IndexAndScore& r) -> bool {
|
||||
if (l.lookaheadScore < r.lookaheadScore) return true;
|
||||
if (l.lookaheadScore > r.lookaheadScore) return false;
|
||||
|
||||
// At this point the scores are tied
|
||||
if (l.immediateScore < r.immediateScore) return true;
|
||||
if (l.immediateScore > r.immediateScore) return false;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
AICommandEvaluator::AICommandEvaluator(
|
||||
const AIScoreCalculator& scorer,
|
||||
const APDCache& apdCache,
|
||||
BattalionTypeGetter battalionTypeGetter)
|
||||
: scorer_(scorer),
|
||||
apdCache_(apdCache),
|
||||
battalionTypeGetter_(std::move(battalionTypeGetter)) {} // Move the function object
|
||||
|
||||
auto AICommandEvaluator::PerformLookahead(
|
||||
const PlayerId pid,
|
||||
const bool isDefender,
|
||||
const int remainingLookahead,
|
||||
const int maxRepeatCount,
|
||||
const std::shared_ptr<ShardokEngine>& innerEngine,
|
||||
const ScoreValue currentUtility,
|
||||
const AIStrategy& attackerStrategy,
|
||||
const CoordsSet& allCastleCoords,
|
||||
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<ScoreValue> p;
|
||||
p.set_value(*cachedScore);
|
||||
return p.get_future();
|
||||
}
|
||||
const auto nextUtility = currentUtility;
|
||||
|
||||
// Check if we've reached the depth limit before making recursive calls
|
||||
if (remainingLookahead <= 0) {
|
||||
// Store the current utility in the transposition table and return it
|
||||
// Note: Store with depth 1 since depth 0 indicates an empty entry in the transposition
|
||||
// table
|
||||
g_transpositionTable.store(innerEngine->GetCurrentGameState(), 1, pid, nextUtility);
|
||||
|
||||
std::promise<ScoreValue> p;
|
||||
p.set_value(nextUtility);
|
||||
return p.get_future();
|
||||
}
|
||||
|
||||
if (const CommandListSPtr nextCommands = innerEngine->GetAvailableCommandsForAIPlayer(pid);
|
||||
nextCommands && !nextCommands->empty()) {
|
||||
// Get the future from FindBestCommand without calling .get()
|
||||
auto bestCommandFuture = FindBestCommand(
|
||||
pid,
|
||||
isDefender,
|
||||
remainingLookahead - 1,
|
||||
maxRepeatCount,
|
||||
*innerEngine,
|
||||
attackerStrategy,
|
||||
nextUtility,
|
||||
allCastleCoords,
|
||||
deadline);
|
||||
|
||||
// Return a future that chains the best command evaluation
|
||||
return std::async(
|
||||
std::launch::deferred,
|
||||
[bestCommandFuture = std::move(bestCommandFuture),
|
||||
innerEngine,
|
||||
pid,
|
||||
nextUtility,
|
||||
remainingLookahead]() mutable -> ScoreValue {
|
||||
const auto [index, type, lookaheadScore, immediateScore] =
|
||||
bestCommandFuture.get();
|
||||
|
||||
ScoreValue resultScore;
|
||||
if (auto& nextCommand =
|
||||
innerEngine->GetAvailableCommandsForAIPlayer(pid)->at(index);
|
||||
nextCommand->GetCommandType() !=
|
||||
net::eagle0::shardok::common::END_TURN_COMMAND) {
|
||||
resultScore = immediateScore;
|
||||
} else {
|
||||
resultScore = nextUtility;
|
||||
}
|
||||
|
||||
// Store in transposition table before returning
|
||||
g_transpositionTable.store(
|
||||
innerEngine->GetCurrentGameState(),
|
||||
remainingLookahead,
|
||||
pid,
|
||||
resultScore);
|
||||
|
||||
return resultScore;
|
||||
});
|
||||
}
|
||||
|
||||
// No commands available, store and return the current utility as a future
|
||||
g_transpositionTable
|
||||
.store(innerEngine->GetCurrentGameState(), remainingLookahead, pid, nextUtility);
|
||||
|
||||
std::promise<ScoreValue> p;
|
||||
p.set_value(nextUtility);
|
||||
return p.get_future();
|
||||
}
|
||||
|
||||
auto AICommandEvaluator::EvaluateWithRandomness(
|
||||
const PlayerId pid,
|
||||
const bool isDefender,
|
||||
const uint32_t commandIndex,
|
||||
const int remainingLookahead,
|
||||
const int maxRepeatCount,
|
||||
const std::shared_ptr<RandomGenerator>& randomGenerator,
|
||||
const ShardokEngine& guessedEngine,
|
||||
const AIStrategy& attackerStrategy,
|
||||
const CoordsSet& allCastleCoords,
|
||||
std::chrono::steady_clock::time_point deadline) const -> ImmediateAndLookaheadScore {
|
||||
ImmediateAndLookaheadScore returnValue{};
|
||||
|
||||
// Check if we've exceeded the deadline
|
||||
if (std::chrono::steady_clock::now() > deadline) {
|
||||
// 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.lookaheadScore = p.get_future();
|
||||
return returnValue;
|
||||
}
|
||||
|
||||
auto innerEngine = std::make_shared<ShardokEngine>(guessedEngine, false);
|
||||
innerEngine->PostCommand(pid, commandIndex, randomGenerator);
|
||||
|
||||
auto innerUtility = scorer_.GuessedStateScore(
|
||||
isDefender,
|
||||
innerEngine->GetCurrentGameState(),
|
||||
attackerStrategy,
|
||||
allCastleCoords);
|
||||
|
||||
returnValue.immediateScore = innerUtility;
|
||||
|
||||
if (remainingLookahead <= 0) {
|
||||
std::promise<ScoreValue> p;
|
||||
returnValue.lookaheadScore = p.get_future();
|
||||
p.set_value(innerUtility);
|
||||
} else {
|
||||
auto lookaheadLambda = [this,
|
||||
pid,
|
||||
isDefender,
|
||||
remainingLookahead,
|
||||
maxRepeatCount,
|
||||
innerEngine,
|
||||
attackerStrategy,
|
||||
innerUtility,
|
||||
&allCastleCoords,
|
||||
deadline]() -> ScoreValue {
|
||||
auto lookaheadFuture = PerformLookahead(
|
||||
pid,
|
||||
isDefender,
|
||||
remainingLookahead,
|
||||
maxRepeatCount,
|
||||
innerEngine,
|
||||
innerUtility,
|
||||
attackerStrategy,
|
||||
allCastleCoords,
|
||||
deadline);
|
||||
return lookaheadFuture.get();
|
||||
};
|
||||
|
||||
#if MULTITHREAD
|
||||
auto launchPolicy = remainingLookahead == 1 ? std::launch::async : std::launch::deferred;
|
||||
returnValue.lookaheadScore = std::async(launchPolicy, lookaheadLambda);
|
||||
#else
|
||||
std::promise<ScoreValue> p;
|
||||
returnValue.lookaheadScore = p.get_future();
|
||||
auto lambdaResult = lookaheadLambda();
|
||||
p.set_value(lambdaResult);
|
||||
#endif
|
||||
}
|
||||
|
||||
return returnValue;
|
||||
}
|
||||
|
||||
auto AICommandEvaluator::FindBestCommand(
|
||||
const PlayerId pid,
|
||||
const bool isDefender,
|
||||
const int remainingLookahead,
|
||||
const int maxRepeatCount,
|
||||
const ShardokEngine& guessedEngine,
|
||||
const AIStrategy& attackerStrategy,
|
||||
const ScoreValue currentUtility,
|
||||
const CoordsSet& allCastleCoords,
|
||||
std::chrono::steady_clock::time_point deadline) const -> std::future<IndexAndScore> {
|
||||
const CommandListSPtr guessedDescriptors = guessedEngine.GetAvailableCommandsForAIPlayer(pid);
|
||||
|
||||
// Filter out obviously bad commands to reduce search space
|
||||
const std::vector<size_t> filteredIndices = AICommandFilter::FilterCommands(
|
||||
guessedDescriptors,
|
||||
pid,
|
||||
isDefender,
|
||||
guessedEngine.GetCurrentGameState(),
|
||||
apdCache_,
|
||||
battalionTypeGetter_);
|
||||
|
||||
const auto& gameState = guessedEngine.GetCurrentGameState();
|
||||
// Calculate minimum hex distance to enemies for this player
|
||||
double minDistToEnemies = std::numeric_limits<double>::max();
|
||||
const auto* units = gameState->units();
|
||||
|
||||
for (size_t i = 0; i < units->size(); ++i) {
|
||||
if (const auto* playerUnit = units->Get(static_cast<unsigned int>(i));
|
||||
playerUnit->player_id() == pid) {
|
||||
const auto& playerCoords = playerUnit->location();
|
||||
|
||||
for (size_t j = 0; j < units->size(); ++j) {
|
||||
if (const auto* enemyUnit = units->Get(static_cast<unsigned int>(j));
|
||||
enemyUnit->player_id() != pid) {
|
||||
const auto& enemyCoords = enemyUnit->location();
|
||||
|
||||
// Proper hex distance calculation using cube coordinates
|
||||
const Cube playerCube = OffsetToCube(playerCoords);
|
||||
const Cube enemyCube = OffsetToCube(enemyCoords);
|
||||
const int hexDistance = CubeDistance(playerCube, enemyCube);
|
||||
|
||||
minDistToEnemies = std::min(minDistToEnemies, static_cast<double>(hexDistance));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (minDistToEnemies == std::numeric_limits<double>::max()) {
|
||||
minDistToEnemies = 0.0; // No enemies found
|
||||
}
|
||||
|
||||
#if LOGGING_
|
||||
// Log command count and distance metrics for performance analysis
|
||||
const auto allCommandCount = guessedDescriptors->size();
|
||||
const auto filteredCommandCount = filteredIndices.size();
|
||||
const int currentRound = gameState->current_round();
|
||||
|
||||
printf("AI_COMMAND_COUNT: Round %d, Player %d, Defender %d, MinDist %.1f, Commands %zu -> %zu "
|
||||
"(%.1f%% filtered)\n",
|
||||
currentRound,
|
||||
static_cast<int>(pid),
|
||||
isDefender ? 1 : 0,
|
||||
minDistToEnemies,
|
||||
allCommandCount,
|
||||
filteredCommandCount,
|
||||
100.0 * (allCommandCount - filteredCommandCount) / allCommandCount);
|
||||
#endif
|
||||
|
||||
const auto commandCount = filteredIndices.size();
|
||||
|
||||
// Structure to hold all command evaluation data
|
||||
struct CommandEvaluation {
|
||||
size_t index;
|
||||
CommandType type;
|
||||
ScoreValue immediateScore;
|
||||
std::vector<std::future<ScoreValue>> lookaheadFutures;
|
||||
};
|
||||
|
||||
std::vector<CommandEvaluation> commandEvaluations(commandCount);
|
||||
|
||||
for (uint32_t index = 0; index < commandCount; index++) {
|
||||
const auto originalIndex = filteredIndices[index];
|
||||
const auto& guessedDescriptor = guessedDescriptors->at(originalIndex);
|
||||
const auto guessedCommandType = guessedDescriptor->GetCommandType();
|
||||
|
||||
commandEvaluations[index].index = originalIndex;
|
||||
commandEvaluations[index].type = guessedCommandType;
|
||||
|
||||
if (guessedCommandType == net::eagle0::shardok::common::END_TURN_COMMAND) {
|
||||
std::promise<ScoreValue> p;
|
||||
commandEvaluations[index].lookaheadFutures.push_back(p.get_future());
|
||||
p.set_value(currentUtility);
|
||||
commandEvaluations[index].immediateScore = currentUtility;
|
||||
} else if (IsDeterministic(guessedCommandType)) {
|
||||
auto [immediateScore, lookaheadScore] = EvaluateWithRandomness(
|
||||
pid,
|
||||
isDefender,
|
||||
originalIndex,
|
||||
remainingLookahead,
|
||||
maxRepeatCount,
|
||||
_averageGenerator,
|
||||
guessedEngine,
|
||||
attackerStrategy,
|
||||
allCastleCoords,
|
||||
deadline);
|
||||
|
||||
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 [successImmediateScore, successLookaheadScore] = EvaluateWithRandomness(
|
||||
pid,
|
||||
isDefender,
|
||||
originalIndex,
|
||||
remainingLookahead,
|
||||
maxRepeatCount,
|
||||
std::make_shared<SequenceRandomGenerator>(
|
||||
std::vector{1.0 - successChance / 2.0}),
|
||||
guessedEngine,
|
||||
attackerStrategy,
|
||||
allCastleCoords,
|
||||
deadline);
|
||||
|
||||
// Failure attempt uses the average of (1 - successChance) and 0 as the roll
|
||||
auto [failureImmediateScore, failureLookaheadScore] = EvaluateWithRandomness(
|
||||
pid,
|
||||
isDefender,
|
||||
originalIndex,
|
||||
remainingLookahead,
|
||||
maxRepeatCount,
|
||||
std::make_shared<SequenceRandomGenerator>(
|
||||
std::vector{(1.0 - successChance) / 2.0}),
|
||||
guessedEngine,
|
||||
attackerStrategy,
|
||||
allCastleCoords,
|
||||
deadline);
|
||||
|
||||
commandEvaluations[index].immediateScore =
|
||||
std::lerp(failureImmediateScore, successImmediateScore, successChance);
|
||||
|
||||
auto successSF = successLookaheadScore.share();
|
||||
auto failureSF = failureLookaheadScore.share();
|
||||
commandEvaluations[index].lookaheadFutures.push_back(std::async(
|
||||
std::launch::deferred,
|
||||
[successSF, failureSF, successChance]() -> double {
|
||||
return std::lerp(failureSF.get(), successSF.get(), successChance);
|
||||
}));
|
||||
} else {
|
||||
ScoreValue sum = 0.0;
|
||||
for (int repeatIteration = 0; repeatIteration < maxRepeatCount; repeatIteration++) {
|
||||
// In each iteration, use a double from [0, 1] as the random roll
|
||||
auto sequence = std::vector{
|
||||
static_cast<double>(repeatIteration) /
|
||||
static_cast<double>(maxRepeatCount - 1)};
|
||||
auto [immediateScore, lookaheadScore] = EvaluateWithRandomness(
|
||||
pid,
|
||||
isDefender,
|
||||
originalIndex,
|
||||
remainingLookahead,
|
||||
maxRepeatCount,
|
||||
std::make_shared<SequenceRandomGenerator>(sequence),
|
||||
guessedEngine,
|
||||
attackerStrategy,
|
||||
allCastleCoords,
|
||||
deadline);
|
||||
|
||||
sum += immediateScore;
|
||||
commandEvaluations[index].lookaheadFutures.push_back(std::move(lookaheadScore));
|
||||
}
|
||||
commandEvaluations[index].immediateScore = sum / maxRepeatCount;
|
||||
}
|
||||
}
|
||||
|
||||
// Return a future that will wait for all evaluations and find the best one
|
||||
return std::async(
|
||||
std::launch::deferred,
|
||||
[evals = std::move(commandEvaluations)]() mutable -> IndexAndScore {
|
||||
std::vector<IndexAndScore> allResults;
|
||||
allResults.reserve(evals.size());
|
||||
|
||||
// Wait for all futures and compute final scores
|
||||
for (auto& eval : evals) {
|
||||
ScoreValue totalLookaheadScore = 0.0;
|
||||
for (auto& future : eval.lookaheadFutures) {
|
||||
totalLookaheadScore += future.get();
|
||||
}
|
||||
ScoreValue avgLookaheadScore =
|
||||
eval.lookaheadFutures.empty()
|
||||
? eval.immediateScore
|
||||
: totalLookaheadScore / eval.lookaheadFutures.size();
|
||||
|
||||
allResults.push_back(IndexAndScore{
|
||||
.index = eval.index,
|
||||
.type = eval.type,
|
||||
.lookaheadScore = avgLookaheadScore,
|
||||
.immediateScore = eval.immediateScore});
|
||||
}
|
||||
// Find the best command using the existing sorter
|
||||
auto bestIt = std::ranges::max_element(allResults, CommandSorter);
|
||||
return *bestIt;
|
||||
});
|
||||
}
|
||||
|
||||
auto AICommandEvaluator::EvaluateCommand(
|
||||
const PlayerId pid,
|
||||
const bool isDefender,
|
||||
const int remainingLookahead,
|
||||
const int maxRepeatCount,
|
||||
const ShardokEngine& guessedEngine,
|
||||
const AIStrategy& attackerStrategy,
|
||||
const ScoreValue currentUtility,
|
||||
const CoordsSet& allCastleCoords,
|
||||
const size_t commandIndex,
|
||||
std::chrono::steady_clock::time_point deadline) const -> std::future<ScoreValue> {
|
||||
const CommandListSPtr guessedDescriptors = guessedEngine.GetAvailableCommandsForAIPlayer(pid);
|
||||
|
||||
if (commandIndex >= guessedDescriptors->size()) {
|
||||
std::promise<ScoreValue> p;
|
||||
p.set_value(currentUtility);
|
||||
return p.get_future();
|
||||
}
|
||||
|
||||
const auto& guessedDescriptor = guessedDescriptors->at(commandIndex);
|
||||
|
||||
if (const auto guessedCommandType = guessedDescriptor->GetCommandType();
|
||||
guessedCommandType == net::eagle0::shardok::common::END_TURN_COMMAND) {
|
||||
std::promise<ScoreValue> p;
|
||||
p.set_value(currentUtility);
|
||||
return p.get_future();
|
||||
} else if (IsDeterministic(guessedCommandType)) {
|
||||
auto [immediateScore, lookaheadScore] = EvaluateWithRandomness(
|
||||
pid,
|
||||
isDefender,
|
||||
commandIndex,
|
||||
remainingLookahead,
|
||||
maxRepeatCount,
|
||||
_averageGenerator,
|
||||
guessedEngine,
|
||||
attackerStrategy,
|
||||
allCastleCoords,
|
||||
deadline);
|
||||
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 [successImmediateScore, successLookaheadScore] = EvaluateWithRandomness(
|
||||
pid,
|
||||
isDefender,
|
||||
commandIndex,
|
||||
remainingLookahead,
|
||||
maxRepeatCount,
|
||||
std::make_shared<SequenceRandomGenerator>(std::vector{1.0 - successChance / 2.0}),
|
||||
guessedEngine,
|
||||
attackerStrategy,
|
||||
allCastleCoords,
|
||||
deadline);
|
||||
|
||||
// Failure attempt
|
||||
auto [failureImmediateScore, failureLookaheadScore] = EvaluateWithRandomness(
|
||||
pid,
|
||||
isDefender,
|
||||
commandIndex,
|
||||
remainingLookahead,
|
||||
maxRepeatCount,
|
||||
std::make_shared<SequenceRandomGenerator>(std::vector{(1.0 - successChance) / 2.0}),
|
||||
guessedEngine,
|
||||
attackerStrategy,
|
||||
allCastleCoords,
|
||||
deadline);
|
||||
|
||||
// Return weighted average of success and failure
|
||||
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<ScoreValue>> lookaheadFutures;
|
||||
lookaheadFutures.reserve(maxRepeatCount);
|
||||
|
||||
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,
|
||||
remainingLookahead,
|
||||
maxRepeatCount,
|
||||
std::make_shared<SequenceRandomGenerator>(sequence),
|
||||
guessedEngine,
|
||||
attackerStrategy,
|
||||
allCastleCoords,
|
||||
deadline);
|
||||
|
||||
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),
|
||||
maxRepeatCount]() mutable -> double {
|
||||
ScoreValue total = 0.0;
|
||||
for (auto& future : lookaheadFutures) { total += future.get(); }
|
||||
return total / maxRepeatCount;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
@@ -1,110 +0,0 @@
|
||||
//
|
||||
// Command evaluator for AI lookahead search.
|
||||
// Separated from AIScoreCalculator to isolate pure state scoring from lookahead logic.
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_AICOMMANDEVALUATOR_HPP
|
||||
#define EAGLE0_AICOMMANDEVALUATOR_HPP
|
||||
|
||||
#include <chrono>
|
||||
#include <future>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
|
||||
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
// Forward declarations
|
||||
class AIScoreCalculator;
|
||||
class ShardokEngine;
|
||||
|
||||
using ScoreValue = double;
|
||||
using CommandType = net::eagle0::shardok::common::CommandType;
|
||||
using BattalionTypeGetter = std::function<BattalionTypeSPtr(BattalionTypeId)>;
|
||||
|
||||
/// Evaluates commands with lookahead using minimax-style search.
|
||||
/// Uses AIScoreCalculator for pure state evaluation, adds recursive lookahead logic.
|
||||
class AICommandEvaluator {
|
||||
public:
|
||||
/// Construct evaluator with a scorer for state evaluation and dependencies for command
|
||||
/// filtering
|
||||
AICommandEvaluator(
|
||||
const AIScoreCalculator& scorer,
|
||||
const APDCache& apdCache,
|
||||
BattalionTypeGetter battalionTypeGetter); // Pass by value
|
||||
|
||||
/// Evaluates the score for a particular command index with lookahead.
|
||||
[[nodiscard]] auto EvaluateCommand(
|
||||
PlayerId pid,
|
||||
bool isDefender,
|
||||
int remainingLookahead,
|
||||
int maxRepeatCount,
|
||||
const ShardokEngine& guessedEngine,
|
||||
const AIStrategy& attackerStrategy,
|
||||
ScoreValue currentUtility,
|
||||
const CoordsSet& allCastleCoords,
|
||||
size_t commandIndex,
|
||||
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 {
|
||||
size_t index;
|
||||
CommandType type;
|
||||
ScoreValue lookaheadScore;
|
||||
ScoreValue immediateScore;
|
||||
};
|
||||
|
||||
[[nodiscard]] auto FindBestCommand(
|
||||
PlayerId pid,
|
||||
bool isDefender,
|
||||
int remainingLookahead,
|
||||
int maxRepeatCount,
|
||||
const ShardokEngine& guessedEngine,
|
||||
const AIStrategy& attackerStrategy,
|
||||
ScoreValue currentUtility,
|
||||
const CoordsSet& allCastleCoords,
|
||||
std::chrono::steady_clock::time_point deadline) const -> std::future<IndexAndScore>;
|
||||
|
||||
private:
|
||||
const AIScoreCalculator& scorer_;
|
||||
const APDCache& apdCache_;
|
||||
BattalionTypeGetter battalionTypeGetter_; // Store by value
|
||||
|
||||
struct ImmediateAndLookaheadScore {
|
||||
ScoreValue immediateScore;
|
||||
std::future<ScoreValue> lookaheadScore;
|
||||
};
|
||||
|
||||
/// Recursive lookahead calculator
|
||||
[[nodiscard]] auto PerformLookahead(
|
||||
PlayerId pid,
|
||||
bool isDefender,
|
||||
int remainingLookahead,
|
||||
int maxRepeatCount,
|
||||
const std::shared_ptr<ShardokEngine>& innerEngine,
|
||||
ScoreValue currentUtility,
|
||||
const AIStrategy& attackerStrategy,
|
||||
const CoordsSet& allCastleCoords,
|
||||
std::chrono::steady_clock::time_point deadline) const -> std::future<ScoreValue>;
|
||||
|
||||
/// Evaluate single command execution with randomness handling
|
||||
[[nodiscard]] auto EvaluateWithRandomness(
|
||||
PlayerId pid,
|
||||
bool isDefender,
|
||||
uint32_t commandIndex,
|
||||
int remainingLookahead,
|
||||
int maxRepeatCount,
|
||||
const std::shared_ptr<class RandomGenerator>& randomGenerator,
|
||||
const ShardokEngine& guessedEngine,
|
||||
const AIStrategy& attackerStrategy,
|
||||
const CoordsSet& allCastleCoords,
|
||||
std::chrono::steady_clock::time_point deadline) const -> ImmediateAndLookaheadScore;
|
||||
};
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_AICOMMANDEVALUATOR_HPP
|
||||
@@ -1,605 +0,0 @@
|
||||
//
|
||||
// Filter obviously bad commands for performance
|
||||
//
|
||||
|
||||
#include "AICommandFilter.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/BattalionType.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokException.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexCubeUtils.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
|
||||
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
using fb::Unit;
|
||||
using net::eagle0::shardok::common::CommandType;
|
||||
|
||||
CoordsSet AICommandFilter::BuildEnemyLocations(const GameStateW& gameState, PlayerId pid) {
|
||||
CoordsSet enemyLocations(gameState->hex_map());
|
||||
const auto* units = gameState->units();
|
||||
|
||||
for (size_t i = 0; i < units->size(); ++i) {
|
||||
const auto* unit = units->Get(static_cast<unsigned int>(i));
|
||||
if (unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT &&
|
||||
unit->player_id() != pid && !unit->hidden() && unit->location().column() != -1) {
|
||||
enemyLocations.Add(unit->location());
|
||||
}
|
||||
}
|
||||
|
||||
return enemyLocations;
|
||||
}
|
||||
|
||||
std::vector<size_t> AICommandFilter::FilterCommands(
|
||||
const CommandListSPtr& commands,
|
||||
PlayerId pid,
|
||||
bool isDefender,
|
||||
const GameStateW& gameState,
|
||||
const APDCache& apdCache,
|
||||
const BattalionTypeGetter& battalionTypeGetter) {
|
||||
std::vector<size_t> filteredIndices;
|
||||
filteredIndices.reserve(commands->size());
|
||||
|
||||
// Build enemy and castle locations once for efficiency
|
||||
const CoordsSet enemyLocations = BuildEnemyLocations(gameState, pid);
|
||||
const CoordsSet castleLocations = AllCastleCoords(gameState->hex_map());
|
||||
|
||||
// Calculate minimum distance to enemies once for all filters
|
||||
const double minDistToEnemies = MinDistanceToEnemyUnits(gameState, pid, enemyLocations);
|
||||
|
||||
for (size_t i = 0; i < commands->size(); ++i) {
|
||||
const auto& cmd = (*commands)[i];
|
||||
|
||||
// Always allow END_TURN commands
|
||||
if (cmd->GetCommandType() == CommandType::END_TURN_COMMAND) {
|
||||
filteredIndices.push_back(i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter obviously bad moves
|
||||
bool shouldFilter = false;
|
||||
|
||||
// Check spell preparation waste
|
||||
if (IsWastefulAction(
|
||||
*cmd,
|
||||
pid,
|
||||
isDefender,
|
||||
gameState,
|
||||
apdCache,
|
||||
battalionTypeGetter,
|
||||
enemyLocations,
|
||||
castleLocations,
|
||||
minDistToEnemies)) {
|
||||
shouldFilter = true;
|
||||
}
|
||||
|
||||
// Check movement waste
|
||||
if (!shouldFilter && IsWastefulMovement(
|
||||
*cmd,
|
||||
pid,
|
||||
isDefender,
|
||||
gameState,
|
||||
apdCache,
|
||||
battalionTypeGetter,
|
||||
enemyLocations,
|
||||
minDistToEnemies)) {
|
||||
shouldFilter = true;
|
||||
}
|
||||
|
||||
// Check strategic blunders
|
||||
if (!shouldFilter && IsStrategicBlunder(
|
||||
*cmd,
|
||||
pid,
|
||||
isDefender,
|
||||
gameState,
|
||||
apdCache,
|
||||
battalionTypeGetter,
|
||||
minDistToEnemies)) {
|
||||
shouldFilter = true;
|
||||
}
|
||||
|
||||
if (!shouldFilter) { filteredIndices.push_back(i); }
|
||||
}
|
||||
|
||||
return filteredIndices;
|
||||
}
|
||||
|
||||
bool AICommandFilter::IsWastefulAction(
|
||||
const ShardokCommand& cmd,
|
||||
PlayerId pid,
|
||||
bool isDefender,
|
||||
const GameStateW& gameState,
|
||||
const APDCache& apdCache,
|
||||
const BattalionTypeGetter& battalionTypeGetter,
|
||||
const CoordsSet& enemyLocations,
|
||||
const CoordsSet& castleLocations,
|
||||
double minDistToEnemies) {
|
||||
// Handle different spell types
|
||||
switch (cmd.GetCommandType()) {
|
||||
case CommandType::METEOR_START_COMMAND: {
|
||||
// Meteor preparation filtering
|
||||
// Meteor takes 3 rounds (start -> target -> cast) and locks the mage in place
|
||||
|
||||
// Asymmetric filtering based on attacker vs defender role
|
||||
if (!isDefender) {
|
||||
// Attackers: Don't start meteor when too far from enemies OR castles
|
||||
// Check distance to castles as well since meteor can deny castle access
|
||||
double minDistToCastles = MinDistanceToCastles(gameState, pid, castleLocations);
|
||||
|
||||
// More aggressive filtering for attackers: filter if >4 hexes from targets
|
||||
// Meteor has range 3, so being >4 hexes from enemies AND castles is wasteful
|
||||
if (minDistToEnemies > 4.0 && minDistToCastles > 4.0) {
|
||||
return true; // Too far from enemies and castles, advance first
|
||||
}
|
||||
}
|
||||
// Defenders: Allow meteor in most cases since it's great for area denial
|
||||
break;
|
||||
}
|
||||
|
||||
case CommandType::START_FIRE_COMMAND: {
|
||||
// Fire spell filtering - be very restrictive for attackers
|
||||
// Fire only affects adjacent tiles and lasts multiple rounds
|
||||
|
||||
if (!isDefender) {
|
||||
// Attackers: Only allow fire if the target location is on or adjacent to an enemy
|
||||
const int targetRow = cmd.GetTargetRow();
|
||||
const int targetCol = cmd.GetTargetColumn();
|
||||
if (targetRow < 0 || targetCol < 0) {
|
||||
throw ShardokInternalErrorException(
|
||||
"START_FIRE_COMMAND missing required target information");
|
||||
}
|
||||
|
||||
const Coords fireLocation(
|
||||
static_cast<int8_t>(targetRow),
|
||||
static_cast<int8_t>(targetCol));
|
||||
|
||||
// Check if any enemy is on the fire location or adjacent to it
|
||||
bool enemyNearFireLocation = false;
|
||||
|
||||
// First check the fire location itself
|
||||
if (enemyLocations.Contains(fireLocation)) {
|
||||
enemyNearFireLocation = true;
|
||||
} else {
|
||||
// Check adjacent tiles (at most 6 coordinates)
|
||||
const auto& adjacentCoords =
|
||||
HexMapUtils::GetAdjacentCoords(gameState->hex_map(), fireLocation);
|
||||
for (const auto& adjCoord : adjacentCoords) {
|
||||
if (enemyLocations.Contains(adjCoord)) {
|
||||
enemyNearFireLocation = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!enemyNearFireLocation) {
|
||||
return true; // No enemies on or adjacent to fire location, fire would be
|
||||
// wasteful
|
||||
}
|
||||
}
|
||||
// Defenders: Allow fire for area denial
|
||||
break;
|
||||
}
|
||||
|
||||
case CommandType::FORTIFY_COMMAND: {
|
||||
// Fortify filtering - attackers shouldn't fortify when far from objectives
|
||||
// Fortify improves defense but also allows an engineer to use a Reduce command next
|
||||
|
||||
if (!isDefender) {
|
||||
// Attackers: Only allow fortify if within 3 hexes of enemies or castles
|
||||
const int unitId = cmd.GetActorUnitId();
|
||||
if (unitId < 0) {
|
||||
throw ShardokInternalErrorException(
|
||||
"FORTIFY_COMMAND missing required actor information");
|
||||
}
|
||||
|
||||
// Get the acting unit directly by ID
|
||||
const Unit* actingUnit = gameState->units()->Get(unitId);
|
||||
// verify the unit is still active
|
||||
if (actingUnit &&
|
||||
actingUnit->status() !=
|
||||
net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT) {
|
||||
actingUnit = nullptr; // Not a valid unit
|
||||
}
|
||||
// Verify it's our unit (not enemy)
|
||||
if (actingUnit && actingUnit->player_id() != pid) { actingUnit = nullptr; }
|
||||
|
||||
if (!actingUnit) {
|
||||
return true; // Unit not found or belongs to enemy
|
||||
}
|
||||
|
||||
const auto& unitCoords = actingUnit->location();
|
||||
const Cube unitCube = OffsetToCube(unitCoords);
|
||||
|
||||
// Check if within 3 hexes of any enemy
|
||||
bool nearObjective = false;
|
||||
for (const auto& enemyCoords : enemyLocations) {
|
||||
const Cube enemyCube = OffsetToCube(enemyCoords);
|
||||
if (const int hexDistance = CubeDistance(unitCube, enemyCube);
|
||||
hexDistance <= 3) {
|
||||
nearObjective = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If not near enemies, check if near castles
|
||||
if (!nearObjective) {
|
||||
for (const auto& castleCoord : castleLocations) {
|
||||
const Cube castleCube = OffsetToCube(castleCoord);
|
||||
const int hexDistance = CubeDistance(unitCube, castleCube);
|
||||
if (hexDistance <= 3) {
|
||||
nearObjective = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!nearObjective) {
|
||||
return true; // Too far from enemies and castles, fortify is wasteful for
|
||||
// attacker
|
||||
}
|
||||
}
|
||||
// Defenders: Allow fortify in most cases since it's about holding positions
|
||||
break;
|
||||
}
|
||||
|
||||
case CommandType::BUILD_BRIDGE_COMMAND:
|
||||
case CommandType::FREEZE_WATER_COMMAND: {
|
||||
// Bridge/freeze filtering - only allow if it creates significant tactical shortcuts
|
||||
// These actions can fail, so we need high confidence of benefit (8+ action points
|
||||
// saved)
|
||||
|
||||
const int unitId = cmd.GetActorUnitId();
|
||||
const int targetRow = cmd.GetTargetRow();
|
||||
const int targetCol = cmd.GetTargetColumn();
|
||||
if (unitId < 0 || targetRow < 0 || targetCol < 0) {
|
||||
throw ShardokInternalErrorException(
|
||||
"BUILD_BRIDGE/FREEZE_WATER_COMMAND missing required actor or target "
|
||||
"information");
|
||||
}
|
||||
|
||||
const Coords waterLocation(
|
||||
static_cast<int8_t>(targetRow),
|
||||
static_cast<int8_t>(targetCol));
|
||||
|
||||
// Get the acting unit directly by ID
|
||||
const Unit* actingUnit = gameState->units()->Get(unitId);
|
||||
// Verify the unit is still active
|
||||
if (actingUnit &&
|
||||
actingUnit->status() != net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT) {
|
||||
actingUnit = nullptr; // Not a valid unit or not ours
|
||||
}
|
||||
// Verify it's our unit (not enemy)
|
||||
if (actingUnit && actingUnit->player_id() != pid) { actingUnit = nullptr; }
|
||||
|
||||
if (!actingUnit) {
|
||||
return true; // Unit not found or belongs to enemy
|
||||
}
|
||||
|
||||
// Get action point distances for this unit's battalion type
|
||||
const auto& battType = battalionTypeGetter(actingUnit->battalion().type());
|
||||
const auto* apd = apdCache->GetRaw(
|
||||
gameState->hex_map(),
|
||||
ActionPointDistancesCache::GetMapId(gameState->hex_map()),
|
||||
battType,
|
||||
false);
|
||||
|
||||
const auto& casterCoords = actingUnit->location();
|
||||
|
||||
// Check if bridge creates significant shortcuts to any tactical objective
|
||||
bool worthwhileShortcut = false;
|
||||
|
||||
// Get tiles on the "other side" of the water (adjacent to bridge location)
|
||||
const auto& adjacentTiles =
|
||||
HexMapUtils::GetAdjacentCoords(gameState->hex_map(), waterLocation);
|
||||
|
||||
// Check shortcuts to enemies
|
||||
for (const auto& enemyCoords : enemyLocations) {
|
||||
const auto currentDistance = apd->Distance(casterCoords, enemyCoords);
|
||||
if (currentDistance == ActionPointDistances::IMPOSSIBLE) continue;
|
||||
|
||||
// Check if going via any adjacent tile creates a shortcut
|
||||
for (const auto& adjacentCoord : adjacentTiles) {
|
||||
const auto distanceToAdjacent = apd->Distance(casterCoords, adjacentCoord);
|
||||
const auto adjacentToObjective = apd->Distance(adjacentCoord, enemyCoords);
|
||||
|
||||
if (distanceToAdjacent != ActionPointDistances::IMPOSSIBLE &&
|
||||
adjacentToObjective != ActionPointDistances::IMPOSSIBLE) {
|
||||
// New route: caster -> adjacent tile -> objective (plus ~2 for crossing)
|
||||
const auto newRouteDistance = distanceToAdjacent + adjacentToObjective + 2;
|
||||
|
||||
if (currentDistance >= newRouteDistance + 8) { // 8+ action points saved
|
||||
worthwhileShortcut = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (worthwhileShortcut) break;
|
||||
}
|
||||
|
||||
// Check shortcuts to castles if no enemy shortcut found
|
||||
if (!worthwhileShortcut) {
|
||||
for (const auto& castleCoord : castleLocations) {
|
||||
const auto currentDistance = apd->Distance(casterCoords, castleCoord);
|
||||
if (currentDistance == ActionPointDistances::IMPOSSIBLE) continue;
|
||||
|
||||
// Check if going via any adjacent tile creates a shortcut
|
||||
for (const auto& adjacentCoord : adjacentTiles) {
|
||||
const auto distanceToAdjacent = apd->Distance(casterCoords, adjacentCoord);
|
||||
const auto adjacentToObjective = apd->Distance(adjacentCoord, castleCoord);
|
||||
|
||||
if (distanceToAdjacent != ActionPointDistances::IMPOSSIBLE &&
|
||||
adjacentToObjective != ActionPointDistances::IMPOSSIBLE) {
|
||||
// New route: caster -> adjacent tile -> objective (plus ~2 for
|
||||
// crossing)
|
||||
const auto newRouteDistance =
|
||||
distanceToAdjacent + adjacentToObjective + 2;
|
||||
|
||||
if (currentDistance >=
|
||||
newRouteDistance + 8) { // 8+ action points saved
|
||||
worthwhileShortcut = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (worthwhileShortcut) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!worthwhileShortcut) {
|
||||
return true; // No significant shortcut found, filter out this bridge/freeze
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case CommandType::REPAIR_COMMAND: {
|
||||
// Repair filtering - filter repairs with high integrity targets
|
||||
// Note: RepairCommandFactory already filters enemy-occupied targets
|
||||
const int targetRow = cmd.GetTargetRow();
|
||||
const int targetCol = cmd.GetTargetColumn();
|
||||
if (targetRow < 0 || targetCol < 0) {
|
||||
throw ShardokInternalErrorException(
|
||||
"REPAIR_COMMAND missing required target information");
|
||||
}
|
||||
|
||||
const Coords repairLocation(
|
||||
static_cast<int8_t>(targetRow),
|
||||
static_cast<int8_t>(targetCol));
|
||||
|
||||
// Check terrain modifiers at target location
|
||||
const auto* terrain = GetTerrain(gameState->hex_map(), repairLocation);
|
||||
const auto& modifier = terrain->modifier();
|
||||
|
||||
// Filter based on integrity thresholds
|
||||
if (modifier.bridge().present()) {
|
||||
// Bridge integrity filtering: >70% is wasteful
|
||||
if (modifier.bridge().integrity() > 70.0f) {
|
||||
return true; // Bridge integrity too high to justify repair
|
||||
}
|
||||
} else if (modifier.castle().present()) {
|
||||
// Castle integrity filtering: >90% is wasteful
|
||||
if (modifier.castle().integrity() > 90.0f) {
|
||||
return true; // Castle integrity too high to justify repair
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case CommandType::EXTINGUISH_FIRE_COMMAND: {
|
||||
// Extinguish fire filtering - don't extinguish fires on enemy-occupied tiles
|
||||
const int targetRow = cmd.GetTargetRow();
|
||||
const int targetCol = cmd.GetTargetColumn();
|
||||
if (targetRow < 0 || targetCol < 0) {
|
||||
throw ShardokInternalErrorException(
|
||||
"EXTINGUISH_FIRE_COMMAND missing required target information");
|
||||
}
|
||||
|
||||
const Coords fireLocation(
|
||||
static_cast<int8_t>(targetRow),
|
||||
static_cast<int8_t>(targetCol));
|
||||
|
||||
// Check if any enemy occupies the fire location - let them burn!
|
||||
std::vector<PlayerId> allyPids; // Empty for now - assume 2-player game
|
||||
if (gameState.GetKnownEnemyOccupant(pid, allyPids, fireLocation)) {
|
||||
return true; // Don't extinguish fires under enemies
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default: return false; // Don't filter other spell types for now
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool AICommandFilter::IsWastefulMovement(
|
||||
const ShardokCommand& cmd,
|
||||
PlayerId pid,
|
||||
bool isDefender,
|
||||
const GameStateW& gameState,
|
||||
const APDCache& apdCache,
|
||||
const BattalionTypeGetter& battalionTypeGetter,
|
||||
const CoordsSet& enemyLocations,
|
||||
double minDistToEnemies) {
|
||||
if (cmd.GetCommandType() != CommandType::MOVE_COMMAND) { return false; }
|
||||
|
||||
// Only filter attacker movement when already fairly far from enemies
|
||||
if (isDefender || minDistToEnemies <= 6.0) {
|
||||
return false; // Don't filter defender movement or when close to enemies
|
||||
}
|
||||
|
||||
// Get unit and target information directly from command
|
||||
const int unitId = cmd.GetActorUnitId();
|
||||
const int targetRow = cmd.GetTargetRow();
|
||||
const int targetCol = cmd.GetTargetColumn();
|
||||
|
||||
// Check if we have the required information
|
||||
if (unitId < 0 || targetRow < 0 || targetCol < 0) {
|
||||
throw ShardokInternalErrorException(
|
||||
"MOVE_COMMAND missing required actor or target information");
|
||||
}
|
||||
|
||||
// Get the acting unit directly by ID
|
||||
const Unit* actingUnit = gameState->units()->Get(unitId);
|
||||
// Verify the unit is still active
|
||||
if (actingUnit &&
|
||||
actingUnit->status() != net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT) {
|
||||
actingUnit = nullptr; // Not a valid unit
|
||||
}
|
||||
// Verify it's our unit (not enemy)
|
||||
if (actingUnit && actingUnit->player_id() != pid) { actingUnit = nullptr; }
|
||||
|
||||
if (!actingUnit) {
|
||||
return false; // Unit not found or belongs to enemy
|
||||
}
|
||||
|
||||
const auto& currentCoords = actingUnit->location();
|
||||
const Coords targetCoordsFlat(static_cast<int8_t>(targetRow), static_cast<int8_t>(targetCol));
|
||||
|
||||
// Get action point distances for this unit's battalion type
|
||||
const auto& battType = battalionTypeGetter(actingUnit->battalion().type());
|
||||
const auto* apd = apdCache->GetRaw(
|
||||
gameState->hex_map(),
|
||||
ActionPointDistancesCache::GetMapId(gameState->hex_map()),
|
||||
battType,
|
||||
false);
|
||||
|
||||
// Calculate action point distance from current position to closest enemy
|
||||
double currentDistToEnemies = std::numeric_limits<double>::max();
|
||||
double targetDistToEnemies = std::numeric_limits<double>::max();
|
||||
|
||||
for (const auto& enemyCoords : enemyLocations) {
|
||||
const auto currentDist = apd->Distance(currentCoords, enemyCoords);
|
||||
const auto targetDist = apd->Distance(targetCoordsFlat, enemyCoords);
|
||||
|
||||
if (currentDist != ActionPointDistances::IMPOSSIBLE) {
|
||||
currentDistToEnemies = std::min(currentDistToEnemies, static_cast<double>(currentDist));
|
||||
}
|
||||
if (targetDist != ActionPointDistances::IMPOSSIBLE) {
|
||||
targetDistToEnemies = std::min(targetDistToEnemies, static_cast<double>(targetDist));
|
||||
}
|
||||
}
|
||||
|
||||
// Filter movement if it takes us significantly farther from all enemies
|
||||
// Only when we're already far away (>6 hexes as checked above)
|
||||
if (currentDistToEnemies != std::numeric_limits<double>::max() &&
|
||||
targetDistToEnemies != std::numeric_limits<double>::max()) {
|
||||
// Filter if move increases distance to enemies by more than 2 action points
|
||||
if (targetDistToEnemies > currentDistToEnemies + 2.0) {
|
||||
return true; // Wasteful move away from enemies when already far
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
bool AICommandFilter::IsStrategicBlunder(
|
||||
const ShardokCommand& /*cmd*/,
|
||||
PlayerId /*pid*/,
|
||||
bool /*isDefender*/,
|
||||
const GameStateW& /*gameState*/,
|
||||
const APDCache& /*apdCache*/,
|
||||
const BattalionTypeGetter& /*battalionTypeGetter*/,
|
||||
double /*minDistToEnemies*/) {
|
||||
// Simplified strategic blunder detection for now
|
||||
// TODO: Implement proper castle abandonment detection
|
||||
// TODO: Use minDistToEnemies for strategic blunder logic
|
||||
return false;
|
||||
}
|
||||
|
||||
double AICommandFilter::MinDistanceToEnemyUnits(
|
||||
const GameStateW& gameState,
|
||||
PlayerId pid,
|
||||
const CoordsSet& enemyLocations) {
|
||||
// Calculate minimum distance from any player unit to any enemy unit
|
||||
double minDistance = std::numeric_limits<double>::max();
|
||||
const auto* units = gameState->units();
|
||||
|
||||
for (size_t i = 0; i < units->size(); ++i) {
|
||||
const auto* playerUnit = units->Get(static_cast<unsigned int>(i));
|
||||
if (playerUnit->status() == net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT &&
|
||||
playerUnit->player_id() == pid) {
|
||||
const auto& playerCoords = playerUnit->location();
|
||||
const Cube playerCube = OffsetToCube(playerCoords);
|
||||
|
||||
for (const auto& enemyCoords : enemyLocations) {
|
||||
const Cube enemyCube = OffsetToCube(enemyCoords);
|
||||
const int hexDistance = CubeDistance(playerCube, enemyCube);
|
||||
minDistance = std::min(minDistance, static_cast<double>(hexDistance));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return minDistance == std::numeric_limits<double>::max() ? 0.0 : minDistance;
|
||||
}
|
||||
|
||||
double AICommandFilter::MinDistanceToCastles(
|
||||
const GameStateW& gameState,
|
||||
PlayerId pid,
|
||||
const CoordsSet& castleLocations) {
|
||||
// Calculate minimum distance from any player unit to any castle
|
||||
double minDistance = std::numeric_limits<double>::max();
|
||||
const auto* units = gameState->units();
|
||||
|
||||
if (castleLocations.empty()) {
|
||||
return 0.0; // No castles found
|
||||
}
|
||||
|
||||
// Find minimum hex distance from any player unit to any castle
|
||||
for (size_t i = 0; i < units->size(); ++i) {
|
||||
const auto* unit = units->Get(static_cast<unsigned int>(i));
|
||||
if (unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT &&
|
||||
unit->player_id() == pid) {
|
||||
const auto& unitCoords = unit->location();
|
||||
const Cube unitCube = OffsetToCube(unitCoords);
|
||||
|
||||
for (const auto& castleCoord : castleLocations) {
|
||||
const Cube castleCube = OffsetToCube(castleCoord);
|
||||
const int hexDistance = CubeDistance(unitCube, castleCube);
|
||||
minDistance = std::min(minDistance, static_cast<double>(hexDistance));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return minDistance == std::numeric_limits<double>::max() ? 0.0 : minDistance;
|
||||
}
|
||||
|
||||
bool AICommandFilter::IsPlayerOutnumbered(
|
||||
const GameStateW& gameState,
|
||||
PlayerId pid,
|
||||
double threshold) {
|
||||
const int playerUnitCount = CountPlayerUnits(gameState, pid);
|
||||
const int enemyUnitCount = CountPlayerUnits(gameState, 1 - pid); // Assumes 2-player game
|
||||
|
||||
if (enemyUnitCount == 0) return false;
|
||||
|
||||
const double ratio = static_cast<double>(playerUnitCount) / static_cast<double>(enemyUnitCount);
|
||||
return ratio < threshold;
|
||||
}
|
||||
|
||||
int AICommandFilter::CountPlayerUnits(const GameStateW& gameState, PlayerId pid) {
|
||||
int count = 0;
|
||||
const auto* units = gameState->units();
|
||||
|
||||
for (size_t i = 0; i < units->size(); ++i) {
|
||||
const auto* unit = units->Get(static_cast<unsigned int>(i));
|
||||
if (unit->status() == net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT &&
|
||||
unit->player_id() == pid) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
bool AICommandFilter::WouldAbandonCriticalCastle(
|
||||
const ShardokCommand& /*cmd*/,
|
||||
PlayerId /*pid*/,
|
||||
const GameStateW& /*gameState*/) {
|
||||
// Simplified implementation - return false for now
|
||||
// TODO: Implement proper castle abandonment detection when API is available
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
@@ -1,107 +0,0 @@
|
||||
//
|
||||
// Filter obviously bad commands to reduce search space for AI
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_AICOMMANDFILTER_HPP
|
||||
#define EAGLE0_AICOMMANDFILTER_HPP
|
||||
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AICommonTypes.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/map/CoordsSet.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
|
||||
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
using GameState = net::eagle0::shardok::storage::fb::GameState;
|
||||
|
||||
/**
|
||||
* Filters obviously bad moves to reduce search space for AI.
|
||||
* This class implements heuristic filtering to eliminate moves that are
|
||||
* strategically bad without requiring deep search to identify.
|
||||
*/
|
||||
class AICommandFilter {
|
||||
public:
|
||||
/**
|
||||
* Filter a list of commands, removing obviously bad ones.
|
||||
* @param commands Original list of all available commands
|
||||
* @param pid Player ID making the move
|
||||
* @param isDefender True if this player is the defender
|
||||
* @param gameState Current game state
|
||||
* @param apdCache Action point distance cache for distance calculations
|
||||
* @param battalionTypeLookup Function to look up battalion types by ID
|
||||
* @return Filtered list of commands worth evaluating
|
||||
*/
|
||||
static std::vector<size_t> FilterCommands(
|
||||
const CommandListSPtr& commands,
|
||||
PlayerId pid,
|
||||
bool isDefender,
|
||||
const GameStateW& gameState,
|
||||
const APDCache& apdCache,
|
||||
const BattalionTypeGetter& battalionTypeLookup);
|
||||
|
||||
private:
|
||||
// Helper to build enemy locations once for efficiency
|
||||
static CoordsSet BuildEnemyLocations(const GameStateW& gameState, PlayerId pid);
|
||||
|
||||
// Spell preparation filters
|
||||
static bool IsWastefulAction(
|
||||
const ShardokCommand& cmd,
|
||||
PlayerId pid,
|
||||
bool isDefender,
|
||||
const GameStateW& gameState,
|
||||
const APDCache& apdCache,
|
||||
const BattalionTypeGetter& battalionTypeLookup,
|
||||
const CoordsSet& enemyLocations,
|
||||
const CoordsSet& castleLocations,
|
||||
double minDistToEnemies);
|
||||
|
||||
// Movement filters
|
||||
static bool IsWastefulMovement(
|
||||
const ShardokCommand& cmd,
|
||||
PlayerId pid,
|
||||
bool isDefender,
|
||||
const GameStateW& gameState,
|
||||
const APDCache& apdCache,
|
||||
const BattalionTypeGetter& battalionTypeLookup,
|
||||
const CoordsSet& enemyLocations,
|
||||
double minDistToEnemies);
|
||||
|
||||
// Strategic blunder filters
|
||||
static bool IsStrategicBlunder(
|
||||
const ShardokCommand& cmd,
|
||||
PlayerId pid,
|
||||
bool isDefender,
|
||||
const GameStateW& gameState,
|
||||
const APDCache& apdCache,
|
||||
const BattalionTypeGetter& battalionTypeLookup,
|
||||
double minDistToEnemies);
|
||||
|
||||
// Helper functions for distance and position analysis
|
||||
static double MinDistanceToEnemyUnits(
|
||||
const GameStateW& gameState,
|
||||
PlayerId pid,
|
||||
const CoordsSet& enemyLocations);
|
||||
|
||||
static double MinDistanceToCastles(
|
||||
const GameStateW& gameState,
|
||||
PlayerId pid,
|
||||
const CoordsSet& castleLocations);
|
||||
|
||||
static bool IsPlayerOutnumbered(const GameStateW& gameState, PlayerId pid, double threshold);
|
||||
|
||||
static int CountPlayerUnits(const GameStateW& gameState, PlayerId pid);
|
||||
|
||||
static bool WouldAbandonCriticalCastle(
|
||||
const ShardokCommand& cmd,
|
||||
PlayerId pid,
|
||||
const GameStateW& gameState);
|
||||
};
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_AICOMMANDFILTER_HPP
|
||||
@@ -1,23 +0,0 @@
|
||||
//
|
||||
// AICommonTypes.hpp
|
||||
// Common type definitions used across AI utility functions
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_AICOMMONTYPES_HPP
|
||||
#define EAGLE0_AICOMMONTYPES_HPP
|
||||
|
||||
#include <functional>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/BattalionType.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
// Function type for looking up battalion types by ID
|
||||
// Used across AI utilities to get battalion type information without
|
||||
// needing to pass the entire scorer object
|
||||
using BattalionTypeGetter = std::function<BattalionTypeSPtr(BattalionTypeId)>;
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_AICOMMONTYPES_HPP
|
||||
@@ -1,25 +0,0 @@
|
||||
//
|
||||
// AI System Types and Configuration
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_AI_CONFIG_HPP
|
||||
#define EAGLE0_AI_CONFIG_HPP
|
||||
|
||||
namespace shardok {
|
||||
|
||||
// Enum for AI algorithm selection
|
||||
enum class AIAlgorithmType {
|
||||
ITERATIVE_DEEPENING, // Default: Minimax with sophisticated randomness
|
||||
MCTS // Monte Carlo Tree Search with multithreading
|
||||
};
|
||||
|
||||
// Enum for scoring calculator selection
|
||||
enum class ScoringCalculatorType {
|
||||
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
|
||||
@@ -4,13 +4,8 @@
|
||||
|
||||
#include "AIDefenderStrategySelector.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <ranges>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackGroups.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreUtilities.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIWaterCrossingCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
|
||||
|
||||
namespace shardok {
|
||||
@@ -19,11 +14,10 @@ constexpr double MAXIMUM_RATIO_FOR_DEFENDER_TO_FLEE = 0.15;
|
||||
constexpr double MINIMUM_RATIO_FOR_DEFENDER_TO_HOLD = 0.60;
|
||||
|
||||
auto AIDefenderStrategySelector::BestDefenderStrategy(
|
||||
const GameStateW& gameState,
|
||||
const GameState* gameState,
|
||||
const CoordsSet& criticalTileCoords,
|
||||
int maxRounds,
|
||||
const APDCache& apdCache,
|
||||
const BattalionTypeGetter& battalionTypeGetter) -> AIStrategy {
|
||||
const SettingsGetter& settings) -> AIStrategy {
|
||||
uint32_t attackerNonUndeadUnitCount = 0;
|
||||
uint32_t attackerNonUndeadUnitNotRequiringWaterCrossingCount = 0;
|
||||
int attackerTroops = 0;
|
||||
@@ -39,7 +33,7 @@ auto AIDefenderStrategySelector::BestDefenderStrategy(
|
||||
player->player_id(),
|
||||
criticalTileCoords,
|
||||
apdCache,
|
||||
battalionTypeGetter);
|
||||
settings);
|
||||
attackerUnitIdsRequiringWaterCrossing.insert(
|
||||
attackerUnitIdsRequiringWaterCrossing.end(),
|
||||
unitIdsRequiringWaterCrossing.begin(),
|
||||
@@ -63,9 +57,7 @@ auto AIDefenderStrategySelector::BestDefenderStrategy(
|
||||
net::eagle0::shardok::storage::fb::BattalionTypeId_UNDEAD) {
|
||||
++attackerNonUndeadUnitCount;
|
||||
|
||||
if (!std::ranges::contains(
|
||||
attackerUnitIdsRequiringWaterCrossing,
|
||||
unit->unit_id())) {
|
||||
if (!common::Contains(attackerUnitIdsRequiringWaterCrossing, unit->unit_id())) {
|
||||
++attackerNonUndeadUnitNotRequiringWaterCrossingCount;
|
||||
}
|
||||
}
|
||||
@@ -74,7 +66,7 @@ auto AIDefenderStrategySelector::BestDefenderStrategy(
|
||||
}
|
||||
}
|
||||
|
||||
const int roundsRemaining = maxRounds - gameState->current_round();
|
||||
const int roundsRemaining = 32 - gameState->current_round();
|
||||
AIStrategy chosenStrategy;
|
||||
|
||||
// Defender will flee if
|
||||
|
||||
@@ -6,23 +6,20 @@
|
||||
#define EAGLE0_AIDEFENDERSTRATEGYSELECTOR_HPP
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AICommonTypes.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
|
||||
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
class AIDefenderStrategySelector {
|
||||
using GameState = net::eagle0::shardok::storage::fb::GameState;
|
||||
|
||||
public:
|
||||
static auto BestDefenderStrategy(
|
||||
const GameStateW& gameState,
|
||||
const GameState* gameState,
|
||||
const CoordsSet& criticalTileCoords,
|
||||
int maxRounds,
|
||||
const APDCache& apdCache,
|
||||
const BattalionTypeGetter& battalionTypeGetter) -> AIStrategy;
|
||||
const SettingsGetter& settings) -> AIStrategy;
|
||||
};
|
||||
} // namespace shardok
|
||||
|
||||
|
||||
@@ -13,8 +13,8 @@ constexpr double kPerUnitDebufDecay = 0.5;
|
||||
constexpr double kDecaySum = kPerUnitDebufDecay / (1 - kPerUnitDebufDecay);
|
||||
|
||||
auto CostsWithoutAndWithBraving(
|
||||
const ActionPointDistances *actionPointDistancesWithoutBraving,
|
||||
const ActionPointDistances *actionPointDistancesWithBraving,
|
||||
const shared_ptr<ActionPointDistances> &actionPointDistancesWithoutBraving,
|
||||
const shared_ptr<ActionPointDistances> &actionPointDistancesWithBraving,
|
||||
const Coords &startLocation,
|
||||
const CoordsSet &targets,
|
||||
int &outPointCostWithoutBraving,
|
||||
@@ -49,8 +49,8 @@ auto DefenderDistanceBuf(
|
||||
const vector<const Unit *> &attackerUnits,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache,
|
||||
const BattalionTypeGetter &battalionTypeGetter,
|
||||
ActionPoints braveWaterCost,
|
||||
const SettingsGetter &settings,
|
||||
const int braveWaterActionPointCost,
|
||||
const bool lateGame,
|
||||
const bool includeUndead) -> double {
|
||||
const auto &locationsToAttackMe = alCache->CachedLocations(defenderLocation, lateGame);
|
||||
@@ -65,22 +65,22 @@ auto DefenderDistanceBuf(
|
||||
vector<WithoutAndWith> pointCosts{};
|
||||
pointCosts.reserve(attackerUnits.size());
|
||||
|
||||
vector<const ActionPointDistances *> notBravingDistances(6, nullptr);
|
||||
vector<const ActionPointDistances *> bravingDistances(6, nullptr);
|
||||
vector<std::shared_ptr<ActionPointDistances>> notBravingDistances(6);
|
||||
vector<std::shared_ptr<ActionPointDistances>> bravingDistances(6);
|
||||
for (const Unit *attacker : attackerUnits) {
|
||||
const int typeInt = attacker->battalion().type();
|
||||
if (notBravingDistances[typeInt] == nullptr) {
|
||||
notBravingDistances[typeInt] = apdCache->GetRaw(
|
||||
notBravingDistances[typeInt] = apdCache->Get(
|
||||
hexMap,
|
||||
mapId,
|
||||
battalionTypeGetter(attacker->battalion().type()),
|
||||
settings.GetBattalionType(attacker->battalion().type()),
|
||||
false);
|
||||
bravingDistances[typeInt] = apdCache->GetRaw(
|
||||
bravingDistances[typeInt] = apdCache->Get(
|
||||
hexMap,
|
||||
mapId,
|
||||
battalionTypeGetter(attacker->battalion().type()),
|
||||
settings.GetBattalionType(attacker->battalion().type()),
|
||||
true,
|
||||
braveWaterCost);
|
||||
braveWaterActionPointCost);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
#define EAGLE0_AIDISTANCEDEBUF_HPP
|
||||
|
||||
#include "AIAttackLocations.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AICommonTypes.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistances.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
@@ -23,8 +23,8 @@ auto DefenderDistanceBuf(
|
||||
const vector<const Unit *> &attackerUnits,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache,
|
||||
const BattalionTypeGetter &battalionTypeGetter,
|
||||
ActionPoints braveWaterCost,
|
||||
const SettingsGetter &settings,
|
||||
int braveWaterActionPointCost,
|
||||
bool lateGame,
|
||||
bool includeUndead) -> double;
|
||||
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
//
|
||||
// AIFleeDecisionCalculator.cpp
|
||||
// eagle0
|
||||
//
|
||||
// Handles AI flee decision logic including combat success estimation
|
||||
// and flee vs fight evaluation for final round scenarios
|
||||
//
|
||||
|
||||
#include "AIFleeDecisionCalculator.hpp"
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIScoreUtilities.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIUnitScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
auto AIFleeDecisionCalculator::GetFleeCommandIndex(
|
||||
const CommandList::const_iterator& fleeCommand,
|
||||
const CommandListSPtr& availableCommands) -> size_t {
|
||||
return static_cast<size_t>(std::distance(availableCommands->begin(), fleeCommand));
|
||||
}
|
||||
|
||||
auto AIFleeDecisionCalculator::EstimateCombatSuccess(
|
||||
PlayerId attackerPlayerId,
|
||||
const GameStateW& gameState,
|
||||
int maxRounds) -> double {
|
||||
if (gameState->status() == nullptr ||
|
||||
gameState->status()->state() !=
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_GAME_RUNNING) {
|
||||
return 1.0; // we're still in set_up so we can't really evaluate
|
||||
}
|
||||
|
||||
// Combat success estimation based on unit power, heroes, and capture dynamics
|
||||
double attackerPower = 0.0;
|
||||
double defenderPower = 0.0;
|
||||
int attackerTroops = 0; // Still track raw troops for special cases
|
||||
int defenderTroops = 0;
|
||||
int attackerUnits = 0;
|
||||
int defenderUnits = 0;
|
||||
int attackerHeroes = 0;
|
||||
int defenderHeroes = 0;
|
||||
bool defenderHasVips = false;
|
||||
|
||||
// Calculate total power and count units/heroes for each side
|
||||
for (const auto* unit : *gameState->units()) {
|
||||
if (unit->status() != net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT) continue;
|
||||
|
||||
const auto* pi = PlayerInfoForPid(gameState, unit->player_id());
|
||||
if (pi == nullptr) continue;
|
||||
|
||||
const int unitTroops = unit->battalion().size();
|
||||
const bool hasHero = unit->has_attached_hero();
|
||||
const double unitPower = ContextFreeUnitValue(unit);
|
||||
|
||||
if (pi->is_defender()) {
|
||||
defenderPower += unitPower;
|
||||
defenderTroops += unitTroops;
|
||||
defenderUnits++;
|
||||
if (hasHero) {
|
||||
defenderHeroes++;
|
||||
if (unit->attached_hero().is_vip()) { defenderHasVips = true; }
|
||||
}
|
||||
} else if (unit->player_id() == attackerPlayerId) {
|
||||
attackerPower += unitPower;
|
||||
attackerTroops += unitTroops;
|
||||
attackerUnits++;
|
||||
if (hasHero) { attackerHeroes++; }
|
||||
}
|
||||
}
|
||||
|
||||
const int roundsRemaining = maxRounds - gameState->current_round();
|
||||
|
||||
// Special case: Attacker has no heroes - automatic loss
|
||||
if (attackerHeroes == 0) {
|
||||
return 0.0; // Cannot win without heroes
|
||||
}
|
||||
|
||||
// Special case: Defender has no heroes - automatic win for attacker
|
||||
if (defenderHeroes == 0) {
|
||||
return 1.0; // Guaranteed win
|
||||
}
|
||||
|
||||
// Special case: Attacker has no troops (but has heroes)
|
||||
if (attackerTroops == 0) {
|
||||
// Very difficult to win with heroes alone
|
||||
return 0.05; // Extremely low chance
|
||||
}
|
||||
|
||||
// Special case: Defender has no troops but has heroes
|
||||
if (defenderTroops == 0) {
|
||||
// Defenders with only heroes are vulnerable to capture
|
||||
// Only truly difficult if time is extremely limited
|
||||
if (roundsRemaining <= 1) {
|
||||
// Last round - very hard to capture all heroes
|
||||
return 0.3; // Low but not impossible
|
||||
} else if (roundsRemaining <= 2) {
|
||||
return 0.6; // Still achievable
|
||||
} else {
|
||||
// With 3+ rounds, capturing defenseless heroes is quite feasible
|
||||
return 0.85; // High probability of success
|
||||
}
|
||||
}
|
||||
|
||||
// Normal case: Both sides have troops
|
||||
// Base probability from power ratio (accounts for unit quality, not just quantity)
|
||||
const double powerRatio = attackerPower / std::max(1.0, defenderPower);
|
||||
double baseProbability = std::min(0.95, std::max(0.05, powerRatio * 0.5));
|
||||
|
||||
// Adjust for time pressure - attackers need to win before time runs out
|
||||
if (roundsRemaining <= 1) {
|
||||
baseProbability *= 0.6; // Severe penalty for last round
|
||||
} else if (roundsRemaining <= 3) {
|
||||
baseProbability *= 0.8; // Moderate penalty
|
||||
}
|
||||
|
||||
// Adjust for unit count (more units = better tactical flexibility)
|
||||
const double unitRatio =
|
||||
static_cast<double>(attackerUnits) / std::max(1.0, static_cast<double>(defenderUnits));
|
||||
if (unitRatio < 0.5) {
|
||||
baseProbability *= 0.8;
|
||||
} else if (unitRatio > 1.5) {
|
||||
baseProbability *= 1.15;
|
||||
}
|
||||
|
||||
// Adjust for hero presence
|
||||
if (defenderHeroes > attackerHeroes && defenderHasVips) {
|
||||
// Defender has more heroes including VIPs - harder to capture
|
||||
baseProbability *= 0.85;
|
||||
}
|
||||
|
||||
return std::min(0.95, std::max(0.05, baseProbability));
|
||||
}
|
||||
|
||||
auto AIFleeDecisionCalculator::EvaluateFleeVsFight(
|
||||
PlayerId playerId,
|
||||
const GameStateW& guessedState,
|
||||
const CommandListSPtr& availableCommands,
|
||||
const CommandList::const_iterator& fleeCommand,
|
||||
int maxRounds,
|
||||
int minimumFleeOddsThreshold,
|
||||
int desperateFleeThreshold,
|
||||
bool enableDebugLogging) -> FleeDecision {
|
||||
// Get flee success odds
|
||||
const int fleeSuccessChance = (*fleeCommand)->GetOddsPercentile();
|
||||
|
||||
if (enableDebugLogging) {
|
||||
printf("AI FinalRound: Evaluating flee (odds=%d%%)...\n", fleeSuccessChance);
|
||||
}
|
||||
|
||||
// Check if flee odds are good enough to attempt
|
||||
if (fleeSuccessChance >= minimumFleeOddsThreshold) {
|
||||
if (enableDebugLogging) {
|
||||
printf("AI FinalRound: Good flee odds (%d%% >= %d%%), choosing flee\n",
|
||||
fleeSuccessChance,
|
||||
minimumFleeOddsThreshold);
|
||||
}
|
||||
return FleeDecision{
|
||||
true,
|
||||
GetFleeCommandIndex(fleeCommand, availableCommands),
|
||||
"Good flee odds"};
|
||||
}
|
||||
|
||||
// Low flee odds - evaluate if fighting might be better
|
||||
const double combatWinChance = EstimateCombatSuccess(playerId, guessedState, maxRounds);
|
||||
|
||||
// If combat situation is hopeless, even bad flee odds are better than certain death
|
||||
if (combatWinChance <= 0.05 && fleeSuccessChance >= desperateFleeThreshold) {
|
||||
if (enableDebugLogging) {
|
||||
printf("AI FinalRound: Combat hopeless (%.1f%%), desperate flee attempt (%d%%)\n",
|
||||
combatWinChance * 100,
|
||||
fleeSuccessChance);
|
||||
}
|
||||
return FleeDecision{
|
||||
true,
|
||||
GetFleeCommandIndex(fleeCommand, availableCommands),
|
||||
"Combat hopeless, desperate flee"};
|
||||
}
|
||||
|
||||
// Detailed flee vs fight comparison
|
||||
const double fleeChance = static_cast<double>(fleeSuccessChance) / 100.0;
|
||||
|
||||
// Compare expected outcomes:
|
||||
// - Flee: fleeChance of survival (not victory, but avoiding loss)
|
||||
// - Fight: combatWinChance of victory (better than survival)
|
||||
|
||||
constexpr double FLEE_VS_COMBAT_MARGIN =
|
||||
0.8; // Require 80% of combat chance to prefer fighting
|
||||
const double adjustedCombatThreshold = combatWinChance * FLEE_VS_COMBAT_MARGIN;
|
||||
|
||||
if (enableDebugLogging) {
|
||||
printf("AI FinalRound: Flee=%d%%, Combat=%.1f%%, Threshold=%.1f%% -> ",
|
||||
fleeSuccessChance,
|
||||
combatWinChance * 100,
|
||||
adjustedCombatThreshold * 100);
|
||||
}
|
||||
|
||||
if (fleeChance > adjustedCombatThreshold) {
|
||||
if (enableDebugLogging) { printf("FLEE (better odds)\n"); }
|
||||
return FleeDecision{
|
||||
true,
|
||||
GetFleeCommandIndex(fleeCommand, availableCommands),
|
||||
"Flee has better expected outcome"};
|
||||
} else {
|
||||
if (enableDebugLogging) { printf("FIGHT (better expected outcome)\n"); }
|
||||
// Return 0 to indicate we should use standard command selection
|
||||
return FleeDecision{
|
||||
false,
|
||||
0, // Will be replaced by StandardChooseCommandIndex
|
||||
"Fighting has better expected outcome"};
|
||||
}
|
||||
}
|
||||
|
||||
auto AIFleeDecisionCalculator::ShouldConsiderFleeing(
|
||||
PlayerId attackerPlayerId,
|
||||
const GameStateW& guessedState,
|
||||
int maxRounds,
|
||||
double fleeConsiderationThreshold) -> bool {
|
||||
// Get combat success probability
|
||||
const double combatSuccessChance =
|
||||
EstimateCombatSuccess(attackerPlayerId, guessedState, maxRounds);
|
||||
|
||||
// Consider fleeing if combat success chance is below threshold
|
||||
return combatSuccessChance < fleeConsiderationThreshold;
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
@@ -1,66 +0,0 @@
|
||||
//
|
||||
// AIFleeDecisionCalculator.hpp
|
||||
// eagle0
|
||||
//
|
||||
// Handles AI flee decision logic including combat success estimation
|
||||
// and flee vs fight evaluation for final round scenarios
|
||||
//
|
||||
|
||||
#ifndef AIFleeDecisionCalculator_hpp
|
||||
#define AIFleeDecisionCalculator_hpp
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCommand.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
class AIFleeDecisionCalculator {
|
||||
public:
|
||||
// Configuration for flee decision thresholds
|
||||
struct FleeThresholds {
|
||||
int minimumFleeOddsThreshold; // Minimum flee success odds to consider fleeing
|
||||
int desperateFleeThreshold; // Flee threshold when combat is hopeless
|
||||
};
|
||||
|
||||
// Result of flee vs fight evaluation
|
||||
struct FleeDecision {
|
||||
bool shouldFlee;
|
||||
size_t commandIndex; // Index of command to execute (flee or fight)
|
||||
const char* reasoning; // Debug explanation of decision
|
||||
};
|
||||
|
||||
// Evaluate whether to flee or fight in the final round
|
||||
[[nodiscard]] static auto EvaluateFleeVsFight(
|
||||
PlayerId playerId,
|
||||
const GameStateW& guessedState,
|
||||
const CommandListSPtr& availableCommands,
|
||||
const CommandList::const_iterator& fleeCommand,
|
||||
int maxRounds,
|
||||
int minimumFleeOddsThreshold,
|
||||
int desperateFleeThreshold,
|
||||
bool enableDebugLogging = false) -> FleeDecision;
|
||||
|
||||
// Estimate probability of combat success for the attacker
|
||||
[[nodiscard]] static auto EstimateCombatSuccess(
|
||||
PlayerId attackerPlayerId,
|
||||
const GameStateW& guessedState,
|
||||
int maxRounds) -> double;
|
||||
|
||||
// Determine if the attacker should consider fleeing based on combat odds
|
||||
// Returns true if fleeing should be considered as an option
|
||||
[[nodiscard]] static auto ShouldConsiderFleeing(
|
||||
PlayerId attackerPlayerId,
|
||||
const GameStateW& guessedState,
|
||||
int maxRounds,
|
||||
double fleeConsiderationThreshold = 0.5) -> bool;
|
||||
|
||||
private:
|
||||
// Helper to get flee command index
|
||||
[[nodiscard]] static auto GetFleeCommandIndex(
|
||||
const CommandList::const_iterator& fleeCommand,
|
||||
const CommandListSPtr& availableCommands) -> size_t;
|
||||
};
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif /* AIFleeDecisionCalculator_hpp */
|
||||
@@ -1,251 +0,0 @@
|
||||
//
|
||||
// Fast heuristic weighting implementation with context-aware logic
|
||||
//
|
||||
|
||||
#include "AIHeuristicWeighting.hpp"
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokException.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistances.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
using CommandType = net::eagle0::shardok::common::CommandType;
|
||||
using Coords = net::eagle0::shardok::storage::fb::Coords;
|
||||
using ProtoCoords = net::eagle0::shardok::common::Coords;
|
||||
|
||||
double AIHeuristicWeighting::GetCommandWeight(
|
||||
const CommandType commandType,
|
||||
const UnitId actorUnitId,
|
||||
const PlayerId actorPlayerId,
|
||||
const Coords& targetCoords,
|
||||
const GameStateW& state,
|
||||
const CoordsSet& castleCoords,
|
||||
const APDCache* apdCache,
|
||||
bool isDefender,
|
||||
std::function<BattalionTypeSPtr(BattalionTypeId)> getBattalionType) {
|
||||
// Fast O(1) heuristic weights based on command type and game context
|
||||
// Higher weight = more likely to select in simulation
|
||||
// 0.0 = never select (filtered out)
|
||||
|
||||
const auto* hexMap = state->hex_map();
|
||||
const auto* units = state->units();
|
||||
const bool hasTarget = (targetCoords.row() >= 0 && targetCoords.column() >= 0);
|
||||
|
||||
switch (commandType) {
|
||||
// === HIGH VALUE OFFENSIVE (10.0) ===
|
||||
// Ranged attacks - very valuable, typically available when in range
|
||||
case CommandType::ARCHERY_COMMAND: return 20.0;
|
||||
case CommandType::LIGHTNING_BOLT_COMMAND: return 10.0;
|
||||
case CommandType::FEAR_COMMAND: return 10.0;
|
||||
|
||||
// Area/tactical spells - high impact
|
||||
case CommandType::METEOR_START_COMMAND: {
|
||||
// METEOR_START doesn't have a target - it's based on actor location
|
||||
if (hasTarget) {
|
||||
throw ShardokInternalErrorException(
|
||||
"METEOR_START_COMMAND should not have target coordinates");
|
||||
}
|
||||
|
||||
// Get actor's location
|
||||
const auto* actorUnit = units->Get(actorUnitId);
|
||||
if (!actorUnit) {
|
||||
throw ShardokInternalErrorException(
|
||||
"METEOR_START_COMMAND actor unit not found in game state");
|
||||
}
|
||||
|
||||
const Coords& actorLocation = actorUnit->location();
|
||||
int enemyCount = 0;
|
||||
|
||||
// Count enemies within meteor range (3 hexes) of actor location
|
||||
constexpr int METEOR_RANGE = 3;
|
||||
const auto tilesInRange = TilesWithinDistance(hexMap, actorLocation, METEOR_RANGE);
|
||||
for (const auto& tileCoords : tilesInRange) {
|
||||
if (const auto* unit = Occupant(units, tileCoords)) {
|
||||
if (unit->player_id() != actorPlayerId) { enemyCount++; }
|
||||
}
|
||||
}
|
||||
|
||||
return 1.0 + (enemyCount * 15.0); // Base 1 + 15 per enemy in range
|
||||
}
|
||||
|
||||
case CommandType::METEOR_TARGET_COMMAND: {
|
||||
// High weight per enemy unit at or adjacent to target
|
||||
if (!hasTarget) {
|
||||
throw ShardokInternalErrorException(
|
||||
"METEOR_TARGET_COMMAND requires target coordinates for heuristic "
|
||||
"weighting");
|
||||
}
|
||||
|
||||
int enemyCount = 0;
|
||||
|
||||
// Count enemies at target
|
||||
if (const auto* targetUnit = Occupant(units, targetCoords)) {
|
||||
if (targetUnit->player_id() != actorPlayerId) { enemyCount++; }
|
||||
}
|
||||
|
||||
// Count enemies adjacent to target
|
||||
for (const auto& neighbor : HexMapUtils::GetAdjacentTiles(hexMap, targetCoords)) {
|
||||
if (const auto* unit = Occupant(units, neighbor.coords)) {
|
||||
if (unit->player_id() != actorPlayerId) { enemyCount++; }
|
||||
}
|
||||
}
|
||||
|
||||
return 1.0 + (enemyCount * 15.0); // Base 1 + 15 per enemy in range
|
||||
}
|
||||
|
||||
case CommandType::RAISE_DEAD_COMMAND: return 10.0;
|
||||
case CommandType::HOLY_WAVE_COMMAND: return 8.0;
|
||||
|
||||
// Fire on enemy (context-dependent)
|
||||
case CommandType::START_FIRE_COMMAND: {
|
||||
// High if enemy at target, low otherwise
|
||||
if (!hasTarget) {
|
||||
throw ShardokInternalErrorException(
|
||||
"START_FIRE_COMMAND requires target coordinates for heuristic weighting");
|
||||
}
|
||||
|
||||
if (const auto* targetUnit = Occupant(units, targetCoords)) {
|
||||
if (targetUnit->player_id() != actorPlayerId) {
|
||||
return 10.0; // Enemy at target - high value
|
||||
}
|
||||
}
|
||||
return 1.0; // No enemy - low value but still valid
|
||||
}
|
||||
|
||||
// === MEDIUM-HIGH OFFENSIVE (5.0-7.0) ===
|
||||
// Direct damage melee
|
||||
case CommandType::MELEE_COMMAND: return 7.0;
|
||||
case CommandType::CHARGE_COMMAND: return 7.0; // Damage + movement
|
||||
case CommandType::CHALLENGE_DUEL_COMMAND: return 5.0;
|
||||
|
||||
// Control and tactical magic
|
||||
case CommandType::CONTROL_COMMAND: return 6.0;
|
||||
case CommandType::METEOR_CAST_COMMAND: return 6.0; // Finish meteor
|
||||
|
||||
case CommandType::REDUCE_COMMAND: {
|
||||
// High if enemy at target, zero otherwise
|
||||
if (!hasTarget) return 0.0;
|
||||
|
||||
if (const auto* targetUnit = Occupant(units, targetCoords)) {
|
||||
if (targetUnit->player_id() != actorPlayerId) {
|
||||
return 10.0; // Enemy at target - very high value
|
||||
}
|
||||
}
|
||||
return 0.0; // No enemy - don't use
|
||||
}
|
||||
|
||||
// === MOVEMENT - Context-dependent ===
|
||||
case CommandType::MOVE_COMMAND: {
|
||||
if (isDefender) {
|
||||
return 0.0; // Defenders don't move
|
||||
}
|
||||
|
||||
// Attackers: weight based on distance improvement towards castle
|
||||
if (!hasTarget) {
|
||||
throw ShardokInternalErrorException(
|
||||
"MOVE_COMMAND requires target coordinates for heuristic weighting");
|
||||
}
|
||||
|
||||
// Get actor unit to determine battalion type and start position
|
||||
const auto* actorUnit = units->Get(actorUnitId);
|
||||
if (!actorUnit) return 4.0; // Default if can't find actor
|
||||
|
||||
// Get battalion type for distance calculation
|
||||
const auto battalionTypeId = actorUnit->battalion().type();
|
||||
const auto battalionTypePtr = getBattalionType(battalionTypeId);
|
||||
if (!battalionTypePtr) return 4.0; // Default if can't get battalion type
|
||||
|
||||
// Get ActionPointDistances for this battalion type
|
||||
const auto mapId = ActionPointDistancesCache::GetMapId(hexMap);
|
||||
const auto* apd = (*apdCache)->GetRaw(hexMap, mapId, battalionTypePtr, false, -1);
|
||||
if (!apd) return 4.0; // Default if can't get distances
|
||||
|
||||
// Calculate minimum distance from start to any castle
|
||||
const Coords startCoords = actorUnit->location();
|
||||
auto minStartDistance = ActionPointDistances::IMPOSSIBLE;
|
||||
for (const auto& castleCoord : castleCoords) {
|
||||
const auto dist = apd->Distance(startCoords, castleCoord);
|
||||
if (dist < minStartDistance) { minStartDistance = dist; }
|
||||
}
|
||||
|
||||
// Calculate minimum distance from end to any castle
|
||||
const Coords& endCoords = targetCoords;
|
||||
auto minEndDistance = ActionPointDistances::IMPOSSIBLE;
|
||||
for (const auto& castleCoord : castleCoords) {
|
||||
const auto dist = apd->Distance(endCoords, castleCoord);
|
||||
if (dist < minEndDistance) { minEndDistance = dist; }
|
||||
}
|
||||
|
||||
// Return weight based on distance improvement
|
||||
// Higher weight if we're moving closer to castle
|
||||
if (minStartDistance == ActionPointDistances::IMPOSSIBLE ||
|
||||
minEndDistance == ActionPointDistances::IMPOSSIBLE) {
|
||||
return 4.0; // Default if distances are impossible
|
||||
}
|
||||
|
||||
const auto improvement = static_cast<double>(minStartDistance - minEndDistance);
|
||||
return std::max(0.0, improvement);
|
||||
}
|
||||
|
||||
case CommandType::BRAVE_WATER_COMMAND: return 3.0; // Tactical movement
|
||||
case CommandType::SCOUT_COMMAND:
|
||||
return 2.0; // Information gathering
|
||||
|
||||
// Terrain manipulation
|
||||
case CommandType::FREEZE_WATER_COMMAND: return 3.0;
|
||||
case CommandType::BUILD_BRIDGE_COMMAND: return 3.0;
|
||||
|
||||
// === LOW VALUE DEFENSIVE/UTILITY (1.0-2.0) ===
|
||||
case CommandType::EXTINGUISH_FIRE_COMMAND: {
|
||||
// High if friendly at target, low otherwise
|
||||
if (!hasTarget) {
|
||||
throw ShardokInternalErrorException(
|
||||
"EXTINGUISH_FIRE_COMMAND requires target coordinates for heuristic "
|
||||
"weighting");
|
||||
}
|
||||
|
||||
if (const auto* targetUnit = Occupant(units, targetCoords)) {
|
||||
if (targetUnit->player_id() == actorPlayerId) {
|
||||
return 8.0; // Friendly at target - high value
|
||||
}
|
||||
}
|
||||
return 1.0; // No friendly - low value but still valid
|
||||
}
|
||||
|
||||
case CommandType::UNIT_REST_COMMAND: return 1.5;
|
||||
case CommandType::FORTIFY_COMMAND: return 2.0;
|
||||
|
||||
// Zero weight - don't use in simulation
|
||||
case CommandType::REPAIR_COMMAND: return 0.0;
|
||||
case CommandType::HIDE_COMMAND: return 0.0;
|
||||
case CommandType::RELEASE_UNIT_COMMAND: return 0.0;
|
||||
|
||||
case CommandType::REINFORCE_COMMAND: return 10.0;
|
||||
case CommandType::MANAGE_PRISONER: return 1.0;
|
||||
|
||||
// === ZERO WEIGHT - NEVER SELECT (0.0) ===
|
||||
// Explicitly bad actions
|
||||
case CommandType::FLEE_COMMAND: return 0.0; // Never flee in simulation
|
||||
case CommandType::RETREAT_COMMAND: return 0.0;
|
||||
case CommandType::BECOME_OUTLAW_COMMAND: return 0.0; // Never become outlaw
|
||||
case CommandType::DISMISS_UNIT_COMMAND:
|
||||
return 0.0; // Never dismiss in combat
|
||||
|
||||
// Actions that are fine as a fallback
|
||||
case CommandType::END_TURN_COMMAND: return 1.0;
|
||||
case CommandType::UNIT_STOP_COMMAND: return 1.0;
|
||||
case CommandType::METEOR_CANCEL_COMMAND: return 1.0;
|
||||
|
||||
// Setup commands (shouldn't appear in combat, but filter anyway)
|
||||
case CommandType::PLACE_UNIT_COMMAND: return 10.0;
|
||||
case CommandType::PLACE_HIDDEN_UNIT_COMMAND: return 1.0;
|
||||
case CommandType::END_PLAYER_SETUP_COMMAND: return 1.0;
|
||||
|
||||
// Unknown/unhandled
|
||||
case CommandType::UNKNOWN_COMMAND:
|
||||
default: return 0.0; // Don't select unknown commands
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
@@ -1,40 +0,0 @@
|
||||
//
|
||||
// Fast heuristic weighting for MCTS simulations
|
||||
// Provides O(1) weights based on command type and context
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_AI_HEURISTIC_WEIGHTING_HPP
|
||||
#define EAGLE0_AI_HEURISTIC_WEIGHTING_HPP
|
||||
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wdeprecated-redundant-constexpr-static-def"
|
||||
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
|
||||
#pragma clang diagnostic pop
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
// Fast heuristic-based command weighting for MCTS simulation policy
|
||||
// Avoids expensive score calculation while maintaining intelligent bias
|
||||
class AIHeuristicWeighting {
|
||||
public:
|
||||
// Get weight for a command using fast heuristics with game context
|
||||
// Returns weight >= 0.0, where 0.0 means "never select" and higher is more likely
|
||||
static double GetCommandWeight(
|
||||
net::eagle0::shardok::common::CommandType commandType,
|
||||
UnitId actorUnitId,
|
||||
PlayerId actorPlayerId,
|
||||
const Coords& targetCoords,
|
||||
const GameStateW& state,
|
||||
const CoordsSet& castleCoords,
|
||||
const APDCache* apdCache,
|
||||
bool isDefender,
|
||||
std::function<BattalionTypeSPtr(BattalionTypeId)> getBattalionType);
|
||||
};
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_AI_HEURISTIC_WEIGHTING_HPP
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
namespace shardok {
|
||||
auto MinimumDistanceAndTarget(
|
||||
const ActionPointDistances *apd,
|
||||
const shared_ptr<ActionPointDistances> &apd,
|
||||
const Coords &origin,
|
||||
const CoordsSet &destinations) -> CoordsAndDistance {
|
||||
CoordsAndDistance min{Coords(-1, -1), ActionPointDistances::IMPOSSIBLE};
|
||||
@@ -20,7 +20,7 @@ auto MinimumDistanceAndTarget(
|
||||
}
|
||||
|
||||
auto MinimumDistance(
|
||||
const ActionPointDistances *apd,
|
||||
const shared_ptr<ActionPointDistances> &apd,
|
||||
const Coords &origin,
|
||||
const CoordsSet &destinations) -> int {
|
||||
return MinimumDistanceAndTarget(apd, origin, destinations).distance;
|
||||
|
||||
@@ -23,12 +23,12 @@ struct CoordsAndDistance {
|
||||
};
|
||||
|
||||
auto MinimumDistanceAndTarget(
|
||||
const ActionPointDistances *apd,
|
||||
const shared_ptr<ActionPointDistances> &apd,
|
||||
const Coords &origin,
|
||||
const CoordsSet &destinations) -> CoordsAndDistance;
|
||||
|
||||
auto MinimumDistance(
|
||||
const ActionPointDistances *apd,
|
||||
const shared_ptr<ActionPointDistances> &apd,
|
||||
const Coords &origin,
|
||||
const CoordsSet &destinations) -> int;
|
||||
|
||||
|
||||
@@ -0,0 +1,904 @@
|
||||
//
|
||||
// Created by dancrosby on 3/4/20.
|
||||
//
|
||||
|
||||
#include "AIScoreCalculator.hpp"
|
||||
|
||||
#include <future>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/common/SequenceRandomGenerator.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/AIVictoryConditionScoreCalculator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIWaterCrossingCommandChooser.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/util/HexMapUtils.hpp"
|
||||
#include "src/main/protobuf/net/eagle0/shardok/api/unit_view.pb.h"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
#define LOGGING_ 0
|
||||
#define MULTITHREAD true
|
||||
|
||||
constexpr double UNITS_BASE_MULTIPLIER = 0.05;
|
||||
|
||||
constexpr double FLEE_UNIT_SCORE = -10000;
|
||||
constexpr double FLEE_CONTROLLING_UNIT_SCORE = -10000;
|
||||
constexpr double CAPTURED_UNIT_SCORE = -10000;
|
||||
constexpr double CAPTURED_VIP_SCORE = -25000;
|
||||
|
||||
constexpr double kMaxProximityBuf = 1.5;
|
||||
constexpr double kDistanceDebufRatio = 8.0;
|
||||
|
||||
using std::async;
|
||||
using std::future;
|
||||
|
||||
using flatbuffers::FlatBufferBuilder;
|
||||
using flatbuffers::Offset;
|
||||
|
||||
using net::eagle0::shardok::api::HeroView;
|
||||
using net::eagle0::shardok::api::UnitView;
|
||||
using GameState = net::eagle0::shardok::storage::fb::GameState;
|
||||
using Unit = net::eagle0::shardok::storage::fb::Unit;
|
||||
|
||||
static const std::vector<double> _averageSequence = {0.5};
|
||||
static const auto _averageGenerator = std::make_shared<SequenceRandomGenerator>(_averageSequence);
|
||||
|
||||
static inline auto IsLateGame(const GameState *gs) { return gs->current_round() > 18; }
|
||||
|
||||
static auto CommandSorter(
|
||||
const AIScoreCalculator::IndexAndScore &l,
|
||||
const AIScoreCalculator::IndexAndScore &r) -> bool {
|
||||
if (l.lookaheadScore < r.lookaheadScore) return true;
|
||||
if (l.lookaheadScore > r.lookaheadScore) return false;
|
||||
|
||||
// At this point the scores are tied
|
||||
if (l.immediateScore < r.immediateScore) return true;
|
||||
if (l.immediateScore > r.immediateScore) return false;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
auto IsDeterministic(const CommandType type) -> bool {
|
||||
switch (type) {
|
||||
case net::eagle0::shardok::common::MOVE_COMMAND:
|
||||
case net::eagle0::shardok::common::CONTROL_COMMAND:
|
||||
case net::eagle0::shardok::common::METEOR_START_COMMAND:
|
||||
case net::eagle0::shardok::common::METEOR_TARGET_COMMAND:
|
||||
case net::eagle0::shardok::common::METEOR_CANCEL_COMMAND:
|
||||
case net::eagle0::shardok::common::END_TURN_COMMAND:
|
||||
case net::eagle0::shardok::common::PLACE_UNIT_COMMAND:
|
||||
case net::eagle0::shardok::common::PLACE_HIDDEN_UNIT_COMMAND:
|
||||
case net::eagle0::shardok::common::UNIT_STOP_COMMAND:
|
||||
case net::eagle0::shardok::common::UNIT_REST_COMMAND:
|
||||
case net::eagle0::shardok::common::FLEE_COMMAND:
|
||||
case net::eagle0::shardok::common::REINFORCE_COMMAND:
|
||||
case net::eagle0::shardok::common::RETREAT_COMMAND:
|
||||
case net::eagle0::shardok::common::END_PLAYER_SETUP_COMMAND:
|
||||
case net::eagle0::shardok::common::HIDE_COMMAND:
|
||||
case net::eagle0::shardok::common::FORTIFY_COMMAND:
|
||||
case net::eagle0::shardok::common::BECOME_OUTLAW_COMMAND:
|
||||
case net::eagle0::shardok::common::HOLY_WAVE_COMMAND:
|
||||
case net::eagle0::shardok::common::REPAIR_COMMAND: return true;
|
||||
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
static auto RecursiveAttackerMultiplierForTargetDistance(
|
||||
const Unit *attackingUnit,
|
||||
vector<TargetAndAttackLocations>::const_iterator &priorityListNext,
|
||||
const vector<TargetAndAttackLocations>::const_iterator &priorityListEnd,
|
||||
const vector<const Unit *> &occupants,
|
||||
const HexMap *map,
|
||||
const MapId &mapId,
|
||||
const SettingsGetter &settings,
|
||||
const ALCache &alCache,
|
||||
const APDCache &apdCache,
|
||||
const ActionPoints braveWaterCost,
|
||||
const bool isLateGame) -> double {
|
||||
if (priorityListNext == priorityListEnd) return 1.0;
|
||||
|
||||
const auto &[target, attackLocations] = *priorityListNext;
|
||||
const Coords &topPriorityTarget = target;
|
||||
|
||||
// If the target is unoccupied or is occupied by this player, give the maximum multiplier, but
|
||||
// also add the bonus for the next up in the priority list
|
||||
if (const Unit *occupant = occupants
|
||||
[topPriorityTarget.row() * map->column_count() + topPriorityTarget.column()];
|
||||
!occupant || occupant->player_id() == attackingUnit->player_id()) {
|
||||
return kMaxProximityBuf + RecursiveAttackerMultiplierForTargetDistance(
|
||||
attackingUnit,
|
||||
++priorityListNext,
|
||||
priorityListEnd,
|
||||
occupants,
|
||||
map,
|
||||
mapId,
|
||||
settings,
|
||||
alCache,
|
||||
apdCache,
|
||||
braveWaterCost,
|
||||
isLateGame);
|
||||
}
|
||||
|
||||
const DIST_T distance = EffectiveDistance(
|
||||
attackingUnit,
|
||||
map,
|
||||
mapId,
|
||||
apdCache,
|
||||
attackLocations,
|
||||
settings,
|
||||
braveWaterCost);
|
||||
|
||||
return kMaxProximityBuf / (1 + distance / kDistanceDebufRatio);
|
||||
}
|
||||
|
||||
auto AttackerMultiplierForTargetDistance(
|
||||
const Unit *attackingUnit,
|
||||
const vector<TargetAndAttackLocations> &priorityList,
|
||||
const vector<const Unit *> &occupants,
|
||||
const HexMap *map,
|
||||
const MapId &mapId,
|
||||
const SettingsGetter &settings,
|
||||
const ALCache &alCache,
|
||||
const APDCache &apdCache,
|
||||
const ActionPoints braveWaterCost,
|
||||
const bool isLateGame) -> double {
|
||||
auto iter = begin(priorityList);
|
||||
return RecursiveAttackerMultiplierForTargetDistance(
|
||||
attackingUnit,
|
||||
iter,
|
||||
end(priorityList),
|
||||
occupants,
|
||||
map,
|
||||
mapId,
|
||||
settings,
|
||||
alCache,
|
||||
apdCache,
|
||||
braveWaterCost,
|
||||
isLateGame);
|
||||
}
|
||||
|
||||
auto AttackerUnitsScore(
|
||||
const GameState *gameState,
|
||||
int roundsRemaining,
|
||||
const SettingsGetter &settings,
|
||||
bool attackerWantsCastles,
|
||||
bool defenderShouldScatter,
|
||||
const vector<TargetPriorityList> &attackerTargetPriorities,
|
||||
const ALCache &alCache,
|
||||
const APDCache &apdCache,
|
||||
const MapId &mapId) -> ScoreValue {
|
||||
bool isLateGame = IsLateGame(gameState);
|
||||
|
||||
std::vector<const Unit *> attackerUnits{};
|
||||
std::vector<const Unit *> defenderUnits{};
|
||||
|
||||
double attackerUnitsValue = 0;
|
||||
double defenderUnitsValue = 0;
|
||||
|
||||
auto occupants = Occupants(
|
||||
*gameState->units(),
|
||||
gameState->hex_map()->row_count(),
|
||||
gameState->hex_map()->column_count());
|
||||
ActionPoints braveWaterCost = settings.Backing().brave_water_action_point_cost();
|
||||
|
||||
for (const Unit *unit : *gameState->units()) {
|
||||
const auto *pi = PlayerInfoForPid(gameState, unit->player_id());
|
||||
if (pi == nullptr) continue;
|
||||
|
||||
switch (unit->status()) {
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT: {
|
||||
if (pi->is_defender()) {
|
||||
defenderUnits.push_back(unit);
|
||||
} else {
|
||||
attackerUnits.push_back(unit);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_CAPTURED_UNIT: {
|
||||
double thisScore = (unit->has_attached_hero() && unit->attached_hero().is_vip())
|
||||
? CAPTURED_VIP_SCORE
|
||||
: CAPTURED_UNIT_SCORE;
|
||||
if (pi->is_defender()) {
|
||||
defenderUnitsValue += thisScore;
|
||||
} else {
|
||||
attackerUnitsValue += thisScore;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_DESTROYED_SUMMONED_UNIT:
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_FLED_UNIT:
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_NEVER_ENTERED_UNIT:
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_OUTLAWED_UNIT:
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT:
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_RETREATED_UNIT: break;
|
||||
|
||||
case net::eagle0::shardok::storage::fb::UnitStatus_UNKNOWN_UNIT:
|
||||
throw ShardokInternalErrorException("Unknown unit status");
|
||||
}
|
||||
}
|
||||
|
||||
double defenderAdvantage = 1.0 + static_cast<double>(gameState->current_round()) / 31.0;
|
||||
|
||||
// Can we cache this somehow, it won't usually change within your turn
|
||||
auto attackLocationsForAttacker = alCache->CachedLocations(defenderUnits, isLateGame);
|
||||
const auto &locationsCausingDanger = attackLocationsForAttacker.AllLocations();
|
||||
|
||||
vector<shared_ptr<ActionPointDistances>> actionPointDistancesByBattalionType(6);
|
||||
for (const Unit *unit : attackerUnits) {
|
||||
auto apdsForType = actionPointDistancesByBattalionType[unit->battalion().type()];
|
||||
if (apdsForType == nullptr) {
|
||||
apdsForType = apdCache->Get(
|
||||
gameState->hex_map(),
|
||||
mapId,
|
||||
settings.GetBattalionType(unit->battalion().type()),
|
||||
false);
|
||||
actionPointDistancesByBattalionType[unit->battalion().type()] = apdsForType;
|
||||
}
|
||||
|
||||
const auto &priorityList = std::find_if(
|
||||
begin(attackerTargetPriorities),
|
||||
end(attackerTargetPriorities),
|
||||
[&unit](const TargetPriorityList &tpl) {
|
||||
return tpl.attackingUnitId == unit->unit_id();
|
||||
});
|
||||
|
||||
// If there are any tiles being targeted, give this unit a multiplier based on how close
|
||||
// they are to being able to attack it
|
||||
double distanceMultiplier = priorityList == end(attackerTargetPriorities)
|
||||
? 1.0
|
||||
: AttackerMultiplierForTargetDistance(
|
||||
unit,
|
||||
priorityList->priorityOrder,
|
||||
occupants,
|
||||
gameState->hex_map(),
|
||||
mapId,
|
||||
settings,
|
||||
alCache,
|
||||
apdCache,
|
||||
braveWaterCost,
|
||||
isLateGame);
|
||||
|
||||
auto uv = UnitValue(
|
||||
unit,
|
||||
true,
|
||||
attackerUnits,
|
||||
attackerWantsCastles,
|
||||
/* includeCastleBonus=*/true,
|
||||
defenderUnits,
|
||||
gameState->hex_map(),
|
||||
roundsRemaining,
|
||||
attackLocationsForAttacker,
|
||||
locationsCausingDanger,
|
||||
apdsForType,
|
||||
settings);
|
||||
|
||||
attackerUnitsValue += distanceMultiplier * uv;
|
||||
}
|
||||
|
||||
auto attackLocationsForDefender = alCache->CachedLocations(attackerUnits, isLateGame);
|
||||
const auto &locationsCausingDangerForAttacker = attackLocationsForDefender.AllLocations();
|
||||
|
||||
for (const Unit *unit : defenderUnits) {
|
||||
auto defenderUnitId = unit->unit_id();
|
||||
|
||||
auto dv = UnitValue(
|
||||
unit,
|
||||
false,
|
||||
attackerUnits,
|
||||
attackerWantsCastles,
|
||||
/* includeCastleBonus=*/!defenderShouldScatter,
|
||||
defenderUnits,
|
||||
gameState->hex_map(),
|
||||
roundsRemaining,
|
||||
attackLocationsForDefender,
|
||||
locationsCausingDangerForAttacker,
|
||||
apdCache->Get(
|
||||
gameState->hex_map(),
|
||||
mapId,
|
||||
settings.GetBattalionType(unit->battalion().type()),
|
||||
false),
|
||||
settings);
|
||||
|
||||
double distanceMultiplier = 1.0;
|
||||
|
||||
// If the defender is trying to scatter, than we want to be as far away from the nearest
|
||||
// attacker as possible, AND as far away from the nearest friendly as possible
|
||||
if (unit->location().row() > -1 && defenderShouldScatter) {
|
||||
CoordsSet myLocationSet(gameState->hex_map());
|
||||
myLocationSet.Add(unit->location());
|
||||
|
||||
DIST_T closestDistanceToEnemy = 999;
|
||||
for (const auto &attackerUnit : attackerUnits) {
|
||||
if (const DIST_T thisDistance = EffectiveDistance(
|
||||
attackerUnit,
|
||||
gameState->hex_map(),
|
||||
mapId,
|
||||
apdCache,
|
||||
myLocationSet,
|
||||
settings,
|
||||
braveWaterCost);
|
||||
thisDistance < closestDistanceToEnemy) {
|
||||
closestDistanceToEnemy = thisDistance;
|
||||
}
|
||||
}
|
||||
|
||||
// If the best we can do puts us very close to the enemy, and the unit is almost
|
||||
// destroyed, return a negative value; better to flee
|
||||
if (unit->can_flee() && closestDistanceToEnemy < 5 &&
|
||||
closestDistanceToEnemy != ActionPointDistances::IMPOSSIBLE &&
|
||||
unit->battalion().size() < 10) {
|
||||
distanceMultiplier = -1;
|
||||
} else {
|
||||
DIST_T closestDistanceToFriendly = 1;
|
||||
if (defenderUnits.size() > 1) {
|
||||
for (const auto &defenderUnit : defenderUnits) {
|
||||
if (defenderUnit->unit_id() != defenderUnitId) {
|
||||
if (const DIST_T thisDistance = EffectiveDistance(
|
||||
defenderUnit,
|
||||
gameState->hex_map(),
|
||||
mapId,
|
||||
apdCache,
|
||||
myLocationSet,
|
||||
settings,
|
||||
braveWaterCost);
|
||||
thisDistance < closestDistanceToEnemy) {
|
||||
closestDistanceToFriendly = thisDistance;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
distanceMultiplier =
|
||||
(closestDistanceToEnemy + closestDistanceToFriendly / 5.0) / 5.0;
|
||||
}
|
||||
}
|
||||
|
||||
defenderUnitsValue += distanceMultiplier * dv;
|
||||
}
|
||||
|
||||
defenderUnitsValue *= defenderAdvantage;
|
||||
|
||||
return attackerUnitsValue - defenderUnitsValue;
|
||||
}
|
||||
|
||||
auto AIScoreCalculator::FleeStrategyScoreForState(
|
||||
const GameState *gameState,
|
||||
const PlayerId playerId) -> ScoreValue {
|
||||
ScoreValue scoreValue = 0.0;
|
||||
|
||||
for (const auto *unit : *gameState->units()) {
|
||||
if (unit->status() != net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT) continue;
|
||||
|
||||
if (unit->player_id() == playerId &&
|
||||
unit->battalion().type() != net::eagle0::shardok::storage::fb::BattalionTypeId_UNDEAD) {
|
||||
scoreValue += FLEE_UNIT_SCORE;
|
||||
|
||||
if (unit->has_attached_hero() &&
|
||||
unit->attached_hero().control_info().controlled_unit_id() != -1) {
|
||||
scoreValue += FLEE_CONTROLLING_UNIT_SCORE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scoreValue;
|
||||
}
|
||||
|
||||
auto AIScoreCalculator::DefenderScatterStrategyScoreForState(
|
||||
const GameState *gameState,
|
||||
const int roundsRemaining,
|
||||
const SettingsGetter &settings,
|
||||
const ALCache &alCache,
|
||||
const APDCache &apdCache) -> ScoreValue {
|
||||
if (gameState->status()->state() ==
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY) {
|
||||
for (const PlayerId winningPid : *gameState->status()->winning_shardok_ids()) {
|
||||
if (winningPid < 0) continue;
|
||||
if (gameState->player_infos()->Get(winningPid)->is_defender()) return INT_MAX;
|
||||
else
|
||||
return INT_MIN;
|
||||
}
|
||||
return INT_MAX;
|
||||
}
|
||||
if (gameState->status()->state() ==
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_DRAW) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const auto mapId = ActionPointDistancesCache::GetMapId(gameState->hex_map());
|
||||
|
||||
const auto unitsTotal = -AttackerUnitsScore(
|
||||
gameState,
|
||||
roundsRemaining,
|
||||
settings,
|
||||
/* attackerWantsCastles=*/false,
|
||||
/* defenderShouldScatter=*/true,
|
||||
{},
|
||||
alCache,
|
||||
apdCache,
|
||||
mapId);
|
||||
|
||||
return unitsTotal;
|
||||
}
|
||||
|
||||
auto AIScoreCalculator::DefenderHoldCastlesStrategyScoreForState(
|
||||
const GameState *gameState,
|
||||
const CoordsSet &castleCoords,
|
||||
const int roundsRemaining,
|
||||
const SettingsGetter &settings,
|
||||
const ALCache &alCache,
|
||||
const APDCache &apdCache) -> ScoreValue {
|
||||
const auto unitsTotal = -AttackerUnitsScore(
|
||||
gameState,
|
||||
roundsRemaining,
|
||||
settings,
|
||||
/* attackerWantsCastles=*/true,
|
||||
/*defenderShouldScatter=*/false,
|
||||
{},
|
||||
alCache,
|
||||
apdCache,
|
||||
ActionPointDistancesCache::GetMapId(gameState->hex_map()));
|
||||
|
||||
// Check the victory condition types
|
||||
ScoreValue victoryConditionTotal = 0.0;
|
||||
|
||||
const PlayerInfo *defenderPi = nullptr;
|
||||
for (const PlayerInfo *pi : *gameState->player_infos()) {
|
||||
if (pi->is_defender()) defenderPi = pi;
|
||||
}
|
||||
|
||||
victoryConditionTotal += DefenderHoldsCriticalTilesVictoryScore(
|
||||
gameState,
|
||||
castleCoords,
|
||||
defenderPi,
|
||||
apdCache,
|
||||
alCache,
|
||||
settings);
|
||||
|
||||
const double unitsMultiplier = static_cast<double>(roundsRemaining) /
|
||||
static_cast<double>(settings.Backing().max_rounds());
|
||||
|
||||
return UNITS_BASE_MULTIPLIER * unitsMultiplier * unitsTotal + victoryConditionTotal;
|
||||
}
|
||||
|
||||
auto AIScoreCalculator::DefenderScoreForState(
|
||||
const GameState *gameState,
|
||||
const AIStrategy &defenderStrategy,
|
||||
const CoordsSet &castleCoords,
|
||||
const int roundsRemaining,
|
||||
const SettingsGetter &settings,
|
||||
const ALCache &alCache,
|
||||
const APDCache &apdCache) -> ScoreValue {
|
||||
if (gameState->status()->state() ==
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY) {
|
||||
for (const PlayerId winningPid : *gameState->status()->winning_shardok_ids()) {
|
||||
if (winningPid < 0) continue;
|
||||
if (defenderStrategy.strategyType == AIStrategy::STRATEGY_FLEE) return 0;
|
||||
if (gameState->player_infos()->Get(winningPid)->is_defender()) return INT_MAX;
|
||||
else
|
||||
return INT_MIN;
|
||||
}
|
||||
return INT_MIN;
|
||||
}
|
||||
if (gameState->status()->state() ==
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_DRAW) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
switch (defenderStrategy.strategyType) {
|
||||
case AIStrategy::STRATEGY_ATTACK_CASTLES:
|
||||
throw ShardokInternalErrorException("Defender cannot use AttackCastlesStrategy");
|
||||
case AIStrategy::STRATEGY_ATTACK_UNITS:
|
||||
throw ShardokInternalErrorException("Defender cannot use AttackUnitsStrategy");
|
||||
case AIStrategy::STRATEGY_CROSS_RIVERS:
|
||||
throw ShardokInternalErrorException("Defender cannot use CrossRiversStrategy");
|
||||
case AIStrategy::STRATEGY_HOLD_CASTLES:
|
||||
return DefenderHoldCastlesStrategyScoreForState(
|
||||
gameState,
|
||||
castleCoords,
|
||||
roundsRemaining,
|
||||
settings,
|
||||
alCache,
|
||||
apdCache);
|
||||
case AIStrategy::STRATEGY_SCATTER:
|
||||
return DefenderScatterStrategyScoreForState(
|
||||
gameState,
|
||||
roundsRemaining,
|
||||
settings,
|
||||
alCache,
|
||||
apdCache);
|
||||
case AIStrategy::STRATEGY_FLEE:
|
||||
for (const auto *pi : *gameState->player_infos()) {
|
||||
if (pi->is_defender()) {
|
||||
return FleeStrategyScoreForState(gameState, pi->player_id());
|
||||
}
|
||||
}
|
||||
throw ShardokInternalErrorException("Unable to find defender for FleeStrategy");
|
||||
}
|
||||
throw ShardokInternalErrorException("Escaped AIStrategy switch");
|
||||
}
|
||||
|
||||
auto AIScoreCalculator::AttackerScoreForState(
|
||||
const GameState *gameState,
|
||||
const AIStrategy &attackerStrategy,
|
||||
const CoordsSet &castleCoords,
|
||||
const int roundsRemaining,
|
||||
const SettingsGetter &settings,
|
||||
const ALCache &alCache,
|
||||
const APDCache &apdCache) -> ScoreValue {
|
||||
if (gameState->status()->state() ==
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY) {
|
||||
for (const PlayerId winningPid : *gameState->status()->winning_shardok_ids()) {
|
||||
if (winningPid < 0) continue;
|
||||
if (attackerStrategy.strategyType == AIStrategy::STRATEGY_FLEE) return 0;
|
||||
if (gameState->player_infos()->Get(winningPid)->is_defender()) return INT_MIN;
|
||||
else
|
||||
return INT_MAX;
|
||||
}
|
||||
return INT_MAX;
|
||||
}
|
||||
if (gameState->status()->state() ==
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_DRAW) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const auto mapId = ActionPointDistancesCache::GetMapId(gameState->hex_map());
|
||||
|
||||
const auto unitsTotal = AttackerUnitsScore(
|
||||
gameState,
|
||||
roundsRemaining,
|
||||
settings,
|
||||
attackerStrategy.strategyType == AIStrategy::STRATEGY_HOLD_CASTLES,
|
||||
/* defenderShouldScatter=*/false,
|
||||
attackerStrategy.targetPriorities,
|
||||
alCache,
|
||||
apdCache,
|
||||
mapId);
|
||||
|
||||
// Check the victory condition types
|
||||
ScoreValue victoryConditionTotal = 0.0;
|
||||
for (const PlayerInfo *pi : *gameState->player_infos()) {
|
||||
if (pi->is_defender()) continue;
|
||||
|
||||
switch (attackerStrategy.strategyType) {
|
||||
case AIStrategy::STRATEGY_CROSS_RIVERS:
|
||||
victoryConditionTotal += AIWaterCrossingCommandChooser(pi->player_id(), apdCache)
|
||||
.WaterCrossingScore(
|
||||
settings,
|
||||
gameState,
|
||||
castleCoords,
|
||||
attackerStrategy.targetLocations);
|
||||
|
||||
case AIStrategy::STRATEGY_ATTACK_CASTLES:
|
||||
case AIStrategy::STRATEGY_ATTACK_UNITS:
|
||||
// already factored into AttackerUnitsScore
|
||||
break;
|
||||
|
||||
case AIStrategy::STRATEGY_HOLD_CASTLES:
|
||||
victoryConditionTotal += AttackerHoldsCriticalTilesVictoryScore(
|
||||
gameState,
|
||||
castleCoords,
|
||||
pi,
|
||||
apdCache,
|
||||
alCache,
|
||||
settings);
|
||||
break;
|
||||
|
||||
case AIStrategy::STRATEGY_SCATTER:
|
||||
throw ShardokInternalErrorException("Attacker cannot use ScatterStrategy");
|
||||
|
||||
case AIStrategy::STRATEGY_FLEE:
|
||||
return FleeStrategyScoreForState(gameState, pi->player_id());
|
||||
}
|
||||
}
|
||||
|
||||
const double unitsMultiplier = static_cast<double>(roundsRemaining) /
|
||||
static_cast<double>(settings.Backing().max_rounds());
|
||||
|
||||
return UNITS_BASE_MULTIPLIER * unitsMultiplier * unitsTotal + victoryConditionTotal;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto AIScoreCalculator::GuessedStateScore(
|
||||
const bool isDefender,
|
||||
const GameState *state,
|
||||
const AIStrategy &aiStrategy,
|
||||
const CoordsSet &allCastleCoords,
|
||||
const SettingsGetter &settingsGetter,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache) -> ScoreValue {
|
||||
const int roundsRemaining = settingsGetter.Backing().max_rounds() - state->current_round();
|
||||
|
||||
if (isDefender) {
|
||||
return DefenderScoreForState(
|
||||
state,
|
||||
aiStrategy,
|
||||
allCastleCoords,
|
||||
roundsRemaining,
|
||||
settingsGetter,
|
||||
alCache,
|
||||
apdCache);
|
||||
} else {
|
||||
return AttackerScoreForState(
|
||||
state,
|
||||
aiStrategy,
|
||||
allCastleCoords,
|
||||
roundsRemaining,
|
||||
settingsGetter,
|
||||
alCache,
|
||||
apdCache);
|
||||
}
|
||||
}
|
||||
|
||||
void PrintCommand(
|
||||
const uint32_t index,
|
||||
const CommandProto &cmd,
|
||||
const GameState *gs,
|
||||
const ScoreValue utility) {
|
||||
printf("i%d %s\n ", index, net::eagle0::shardok::common::CommandType_Name(cmd.type()).c_str());
|
||||
|
||||
if (cmd.has_actor()) {
|
||||
const auto actor = cmd.actor().value();
|
||||
auto &loc = gs->units()->Get(actor)->location();
|
||||
printf("a%d(%d, %d) ", cmd.actor().value(), loc.row(), loc.column());
|
||||
}
|
||||
if (cmd.has_target()) { printf("t(%d, %d) ", cmd.target().row(), cmd.target().column()); }
|
||||
printf("u%f\n", utility);
|
||||
}
|
||||
|
||||
auto AIScoreCalculator::BasicLookaheadCalculator(
|
||||
const PlayerId pid,
|
||||
const bool isDefender,
|
||||
const int maxRepeatCount,
|
||||
const shared_ptr<ShardokEngine> &innerEngine,
|
||||
const ScoreValue currentUtility,
|
||||
const AIStrategy &attackerStrategy,
|
||||
const SettingsGetter &settingsGetter,
|
||||
const CoordsSet &allCastleCoords,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache) -> ScoreValue {
|
||||
const auto nextUtility = currentUtility;
|
||||
|
||||
if (const CommandListSPtr nextCommands = innerEngine->GetAvailableCommandsForAIPlayer(pid);
|
||||
nextCommands && !nextCommands->empty()) {
|
||||
const auto [index, type, lookaheadScore, immediateScore] = BestCommandIndex(
|
||||
pid,
|
||||
isDefender,
|
||||
-1,
|
||||
maxRepeatCount,
|
||||
*innerEngine,
|
||||
attackerStrategy,
|
||||
nextUtility,
|
||||
settingsGetter,
|
||||
allCastleCoords,
|
||||
apdCache,
|
||||
alCache);
|
||||
|
||||
if (auto &nextCommand = innerEngine->GetAvailableCommandsForAIPlayer(pid)->at(index);
|
||||
nextCommand->GetCommandType() != net::eagle0::shardok::common::END_TURN_COMMAND) {
|
||||
return immediateScore;
|
||||
}
|
||||
}
|
||||
|
||||
return nextUtility;
|
||||
}
|
||||
|
||||
auto AIScoreCalculator::CalcOne(
|
||||
PlayerId pid,
|
||||
bool isDefender,
|
||||
uint32_t commandIndex,
|
||||
int remainingLookahead,
|
||||
int maxRepeatCount,
|
||||
const std::shared_ptr<RandomGenerator> &randomGenerator,
|
||||
const ShardokEngine &guessedEngine,
|
||||
const AIStrategy &attackerStrategy,
|
||||
const SettingsGetter &settingsGetter,
|
||||
const CoordsSet &allCastleCoords,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache) -> ImmediateAndLookaheadScore {
|
||||
ImmediateAndLookaheadScore returnValue{};
|
||||
|
||||
auto innerEngine = std::make_shared<ShardokEngine>(guessedEngine, false);
|
||||
innerEngine->PostCommand(pid, commandIndex, randomGenerator);
|
||||
|
||||
auto innerUtility = AIScoreCalculator::GuessedStateScore(
|
||||
isDefender,
|
||||
innerEngine->GetCurrentGameState(),
|
||||
attackerStrategy,
|
||||
allCastleCoords,
|
||||
settingsGetter,
|
||||
apdCache,
|
||||
alCache);
|
||||
#if LOGGING_
|
||||
if (remainingLookahead == 1 && (commandIndex == 265 || commandIndex == 25)) {
|
||||
printf("Here we are %d\n", commandIndex);
|
||||
log = true;
|
||||
|
||||
auto cmd = guessedEngine.GetAvailableCommands(pid, false)[commandIndex];
|
||||
PrintCommand(commandIndex, cmd, guessedEngine.GetCurrentGameState(), innerUtility);
|
||||
}
|
||||
#endif
|
||||
|
||||
returnValue.immediateScore = innerUtility;
|
||||
|
||||
if (remainingLookahead == -1) {
|
||||
std::promise<ScoreValue> p;
|
||||
returnValue.lookaheadScore = p.get_future();
|
||||
p.set_value(innerUtility);
|
||||
} else {
|
||||
auto lookaheadLambda = [pid,
|
||||
isDefender,
|
||||
maxRepeatCount,
|
||||
innerEngine,
|
||||
attackerStrategy,
|
||||
innerUtility,
|
||||
&settingsGetter,
|
||||
&allCastleCoords,
|
||||
&apdCache,
|
||||
&alCache]() -> ScoreValue {
|
||||
return BasicLookaheadCalculator(
|
||||
pid,
|
||||
isDefender,
|
||||
maxRepeatCount,
|
||||
innerEngine,
|
||||
innerUtility,
|
||||
attackerStrategy,
|
||||
settingsGetter,
|
||||
allCastleCoords,
|
||||
apdCache,
|
||||
alCache);
|
||||
};
|
||||
|
||||
#if MULTITHREAD
|
||||
returnValue.lookaheadScore = std::async(std::launch::async, lookaheadLambda);
|
||||
#else
|
||||
std::promise<ScoreValue> p;
|
||||
returnValue.lookaheadScore = p.get_future();
|
||||
auto lambdaResult = lookaheadLambda();
|
||||
p.set_value(lambdaResult);
|
||||
#endif
|
||||
}
|
||||
|
||||
return returnValue;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto AIScoreCalculator::BestCommandIndex(
|
||||
const PlayerId pid,
|
||||
const bool isDefender,
|
||||
const int remainingLookahead,
|
||||
const int maxRepeatCount,
|
||||
const ShardokEngine &guessedEngine,
|
||||
const AIStrategy &attackerStrategy,
|
||||
const ScoreValue currentUtility,
|
||||
const SettingsGetter &settingsGetter,
|
||||
const CoordsSet &allCastleCoords,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache) -> IndexAndScore {
|
||||
const CommandListSPtr guessedDescriptors = guessedEngine.GetAvailableCommandsForAIPlayer(pid);
|
||||
|
||||
const auto commandCount = guessedDescriptors->size();
|
||||
|
||||
vector<IndexAndScore> allIndices(commandCount);
|
||||
|
||||
// Primary index is the command index; vector may contain repeated attempts
|
||||
vector<vector<future<ScoreValue>>> scoreFutures(commandCount);
|
||||
|
||||
for (uint32_t index = 0; index < commandCount; index++) {
|
||||
const auto &guessedDescriptor = guessedDescriptors->at(index);
|
||||
const auto guessedCommandType = guessedDescriptor->GetCommandType();
|
||||
|
||||
allIndices[index].index = index;
|
||||
allIndices[index].type = guessedCommandType;
|
||||
|
||||
if (guessedCommandType == net::eagle0::shardok::common::END_TURN_COMMAND) {
|
||||
std::promise<ScoreValue> p;
|
||||
scoreFutures[index].push_back(p.get_future());
|
||||
p.set_value(currentUtility);
|
||||
allIndices[index].immediateScore = currentUtility;
|
||||
} else if (IsDeterministic(guessedCommandType)) {
|
||||
auto [immediateScore, lookaheadScore] =
|
||||
CalcOne(pid,
|
||||
isDefender,
|
||||
index,
|
||||
remainingLookahead,
|
||||
maxRepeatCount,
|
||||
_averageGenerator,
|
||||
guessedEngine,
|
||||
attackerStrategy,
|
||||
settingsGetter,
|
||||
allCastleCoords,
|
||||
apdCache,
|
||||
alCache);
|
||||
|
||||
allIndices[index].immediateScore = immediateScore;
|
||||
scoreFutures[index].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 (so 30% chance -> rolling
|
||||
// 85)
|
||||
auto [successImmediateScore, successLookaheadScore] =
|
||||
CalcOne(pid,
|
||||
isDefender,
|
||||
index,
|
||||
remainingLookahead,
|
||||
maxRepeatCount,
|
||||
std::make_shared<SequenceRandomGenerator>(
|
||||
std::vector{1.0 - successChance / 2.0}),
|
||||
guessedEngine,
|
||||
attackerStrategy,
|
||||
settingsGetter,
|
||||
allCastleCoords,
|
||||
apdCache,
|
||||
alCache);
|
||||
|
||||
// second attempt uses the average of (1 - successChance) and 0 as the roll (so 30%
|
||||
// chance -> rolling 15)
|
||||
auto [failureImmediateScore, failureLookaheadScore] =
|
||||
CalcOne(pid,
|
||||
isDefender,
|
||||
index,
|
||||
remainingLookahead,
|
||||
maxRepeatCount,
|
||||
std::make_shared<SequenceRandomGenerator>(
|
||||
std::vector{(1.0 - successChance) / 2.0}),
|
||||
guessedEngine,
|
||||
attackerStrategy,
|
||||
settingsGetter,
|
||||
allCastleCoords,
|
||||
apdCache,
|
||||
alCache);
|
||||
|
||||
allIndices[index].immediateScore =
|
||||
std::lerp(failureImmediateScore, successImmediateScore, successChance);
|
||||
|
||||
auto successSF = successLookaheadScore.share();
|
||||
auto failureSF = failureLookaheadScore.share();
|
||||
scoreFutures[index].push_back(
|
||||
std::async(std::launch::deferred, [successSF, failureSF, successChance]() {
|
||||
return std::lerp(failureSF.get(), successSF.get(), successChance);
|
||||
}));
|
||||
} else {
|
||||
ScoreValue sum = 0.0;
|
||||
for (int repeatIteration = 0; repeatIteration < maxRepeatCount; repeatIteration++) {
|
||||
// In each iteration, use a double from [0, 1] as the random roll
|
||||
auto sequence = std::vector{
|
||||
static_cast<double>(repeatIteration) /
|
||||
static_cast<double>(maxRepeatCount - 1)};
|
||||
auto [immediateScore, lookaheadScore] =
|
||||
CalcOne(pid,
|
||||
isDefender,
|
||||
index,
|
||||
remainingLookahead,
|
||||
maxRepeatCount,
|
||||
std::make_shared<SequenceRandomGenerator>(sequence),
|
||||
guessedEngine,
|
||||
attackerStrategy,
|
||||
settingsGetter,
|
||||
allCastleCoords,
|
||||
apdCache,
|
||||
alCache);
|
||||
|
||||
sum += immediateScore;
|
||||
scoreFutures[index].push_back(std::move(lookaheadScore));
|
||||
}
|
||||
allIndices[index].immediateScore = sum / maxRepeatCount;
|
||||
}
|
||||
}
|
||||
|
||||
for (uint32_t i = 0; i < commandCount; i++) {
|
||||
const auto count = static_cast<ScoreValue>(scoreFutures[i].size());
|
||||
ScoreValue total = 0.0;
|
||||
for (auto &oneFuture : scoreFutures[i]) { total += oneFuture.get(); }
|
||||
allIndices[i].lookaheadScore = total / count;
|
||||
}
|
||||
|
||||
return *std::max_element(std::begin(allIndices), std::end(allIndices), CommandSorter);
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
@@ -0,0 +1,139 @@
|
||||
//
|
||||
// Created by dancrosby on 3/4/20.
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_AISCORECALCULATOR_HPP
|
||||
#define EAGLE0_AISCORECALCULATOR_HPP
|
||||
|
||||
#include <flatbuffers/flatbuffers.h>
|
||||
|
||||
#include <future>
|
||||
#include <utility>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
|
||||
#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"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/view_filters/GameStateGuesser.hpp"
|
||||
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state.hpp"
|
||||
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
|
||||
#include "src/main/protobuf/net/eagle0/shardok/api/game_state_view.pb.h"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
using net::eagle0::shardok::api::GameStateView;
|
||||
using GameState = net::eagle0::shardok::storage::fb::GameState;
|
||||
using shardok::PlayerId;
|
||||
using std::future;
|
||||
using std::vector;
|
||||
|
||||
using ScoreValue = double;
|
||||
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
|
||||
|
||||
class AIScoreCalculator {
|
||||
public:
|
||||
struct IndexAndScore {
|
||||
size_t index;
|
||||
net::eagle0::shardok::common::CommandType type;
|
||||
ScoreValue lookaheadScore;
|
||||
ScoreValue immediateScore;
|
||||
};
|
||||
|
||||
private:
|
||||
[[nodiscard]] static auto DefenderScatterStrategyScoreForState(
|
||||
const GameState *gameState,
|
||||
int roundsRemaining,
|
||||
const SettingsGetter &settings,
|
||||
const ALCache &alCache,
|
||||
const APDCache &apdCache) -> ScoreValue;
|
||||
|
||||
[[nodiscard]] static auto DefenderHoldCastlesStrategyScoreForState(
|
||||
const GameState *gameState,
|
||||
const CoordsSet &castleCoords,
|
||||
int roundsRemaining,
|
||||
const SettingsGetter &settings,
|
||||
const ALCache &alCache,
|
||||
const APDCache &apdCache) -> ScoreValue;
|
||||
|
||||
[[nodiscard]] static auto FleeStrategyScoreForState(
|
||||
const GameState *gameState,
|
||||
PlayerId playerId) -> ScoreValue;
|
||||
|
||||
[[nodiscard]] static auto DefenderScoreForState(
|
||||
const GameState *gameState,
|
||||
const AIStrategy &defenderStrategy,
|
||||
const CoordsSet &castleCoords,
|
||||
int roundsRemaining,
|
||||
const SettingsGetter &settings,
|
||||
const ALCache &alCache,
|
||||
const APDCache &apdCache) -> ScoreValue;
|
||||
|
||||
[[nodiscard]] static auto AttackerScoreForState(
|
||||
const GameState *gameState,
|
||||
const AIStrategy &attackerStrategy,
|
||||
const CoordsSet &castleCoords,
|
||||
int roundsRemaining,
|
||||
const SettingsGetter &settings,
|
||||
const ALCache &alCache,
|
||||
const APDCache &apdCache) -> ScoreValue;
|
||||
|
||||
struct ImmediateAndLookaheadScore {
|
||||
ScoreValue immediateScore;
|
||||
future<ScoreValue> lookaheadScore;
|
||||
};
|
||||
|
||||
static auto BasicLookaheadCalculator(
|
||||
PlayerId pid,
|
||||
bool isDefender,
|
||||
int maxRepeatCount,
|
||||
const shared_ptr<ShardokEngine> &innerEngine,
|
||||
ScoreValue currentUtility,
|
||||
const AIStrategy &attackerStrategy,
|
||||
const SettingsGetter &settingsGetter,
|
||||
const CoordsSet &allCastleCoords,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache) -> ScoreValue;
|
||||
|
||||
static auto CalcOne(
|
||||
PlayerId pid,
|
||||
bool isDefender,
|
||||
uint32_t commandIndex,
|
||||
int remainingLookahead,
|
||||
int maxRepeatCount,
|
||||
const std::shared_ptr<RandomGenerator> &randomGenerator,
|
||||
const ShardokEngine &guessedEngine,
|
||||
const AIStrategy &attackerStrategy,
|
||||
const SettingsGetter &settingsGetter,
|
||||
const CoordsSet &allCastleCoords,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache) -> ImmediateAndLookaheadScore;
|
||||
|
||||
public:
|
||||
[[nodiscard]] static auto GuessedStateScore(
|
||||
bool isDefender,
|
||||
const GameState *state,
|
||||
const AIStrategy &aiStrategy,
|
||||
const CoordsSet &allCastleCoords,
|
||||
const SettingsGetter &settingsGetter,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache) -> ScoreValue;
|
||||
|
||||
[[nodiscard]] static auto BestCommandIndex(
|
||||
PlayerId pid,
|
||||
bool isDefender,
|
||||
int remainingLookahead,
|
||||
int maxRepeatCount,
|
||||
const ShardokEngine &guessedEngine,
|
||||
const AIStrategy &attackerStrategy,
|
||||
ScoreValue currentUtility,
|
||||
const SettingsGetter &settingsGetter,
|
||||
const CoordsSet &allCastleCoords,
|
||||
const APDCache &apdCache,
|
||||
const ALCache &alCache) -> IndexAndScore;
|
||||
};
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_AISCORECALCULATOR_HPP
|
||||
@@ -16,7 +16,7 @@ auto HasAttachedHeroWithProfession(
|
||||
unit->attached_hero().profession_info().profession() == profession;
|
||||
}
|
||||
|
||||
auto CastleClaimCapableAttackerUnitCount(const GameStateW &gameState) -> int {
|
||||
auto CastleClaimCapableAttackerUnitCount(const GameState *gameState) -> int {
|
||||
int count = 0;
|
||||
|
||||
for (const auto *unit : *gameState->units()) {
|
||||
@@ -32,7 +32,7 @@ auto CastleClaimCapableAttackerUnitCount(const GameStateW &gameState) -> int {
|
||||
return count;
|
||||
}
|
||||
|
||||
auto PlayerInfoForPid(const GameStateW &gs, const PlayerId pid) -> const PlayerInfo * {
|
||||
auto PlayerInfoForPid(const GameState *gs, const PlayerId pid) -> const PlayerInfo * {
|
||||
if (gs->player_infos()) {
|
||||
for (const auto &pi : *gs->player_infos()) {
|
||||
if (pi->player_id() == pid) return pi;
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
|
||||
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state.hpp"
|
||||
#include "src/main/flatbuffer/net/eagle0/shardok/storage/unit.hpp"
|
||||
@@ -26,8 +25,8 @@ auto HasAttachedHeroWithProfession(
|
||||
const Unit *unit,
|
||||
net::eagle0::shardok::storage::fb::Profession profession) -> bool;
|
||||
|
||||
auto CastleClaimCapableAttackerUnitCount(const GameStateW &gameState) -> int;
|
||||
auto PlayerInfoForPid(const GameStateW &, PlayerId pid) -> const PlayerInfo *;
|
||||
auto CastleClaimCapableAttackerUnitCount(const GameState *gameState) -> int;
|
||||
auto PlayerInfoForPid(const GameState *gs, PlayerId pid) -> const PlayerInfo *;
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
//
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
|
||||
|
||||
namespace shardok {
|
||||
AIStrategy FleeStrategy = AIStrategy{AIStrategy::STRATEGY_FLEE};
|
||||
AIStrategy HoldCastlesStrategy = AIStrategy{AIStrategy::STRATEGY_HOLD_CASTLES};
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
//
|
||||
// Created by Dan Crosby on 07/04/25.
|
||||
//
|
||||
|
||||
#include "AITimeBudget.hpp"
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/fb_helpers/FlatbufferWrapper.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexCubeUtils.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
|
||||
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state.hpp"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
// Static member definition
|
||||
std::atomic<int> AIEvaluationCounter::activeCount{0};
|
||||
|
||||
AIEvaluationCounter::AIEvaluationCounter() { activeCount++; }
|
||||
|
||||
AIEvaluationCounter::~AIEvaluationCounter() { activeCount--; }
|
||||
|
||||
int AIEvaluationCounter::GetCurrentCount() { return activeCount.load(); }
|
||||
|
||||
auto CalculateTimeBudget(
|
||||
const PlayerId playerId,
|
||||
const GameSettingsSPtr &settings,
|
||||
const GameStateW &state,
|
||||
const size_t numCommands) -> AITimeBudget {
|
||||
const auto settingsGetter = settings->GetGetter();
|
||||
const auto castleCoords = AllCastleCoords(state->hex_map());
|
||||
|
||||
// Check if we're in setup phase
|
||||
const bool isSetupPhase = state->status()->state() ==
|
||||
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP;
|
||||
|
||||
// Get maximum budget cap from settings (in seconds)
|
||||
const double maxBudgetSeconds =
|
||||
settingsGetter.Backing().lookahead_time_budget_maximum_seconds();
|
||||
const double maxBudgetMs = maxBudgetSeconds * 1000.0;
|
||||
|
||||
// During setup, use the setup-specific time budget
|
||||
if (isSetupPhase) {
|
||||
// Dynamic budget: msPerCommand × numCommands
|
||||
const double msPerCommand =
|
||||
settingsGetter.Backing().lookahead_time_budget_per_command_setup_ms();
|
||||
const double budgetMs = msPerCommand * static_cast<double>(numCommands);
|
||||
|
||||
// Clamp to reasonable bounds: 200ms minimum, maxBudgetMs maximum
|
||||
const auto clampedBudgetMs = std::clamp(budgetMs, 200.0, maxBudgetMs);
|
||||
const auto remainingBudget =
|
||||
std::chrono::milliseconds(static_cast<int64_t>(clampedBudgetMs));
|
||||
|
||||
const size_t minDepth = settingsGetter.Backing().min_lookahead_turns();
|
||||
|
||||
return AITimeBudget{
|
||||
.remainingBudget = remainingBudget,
|
||||
.minDepthRequired = minDepth,
|
||||
.isCloseToEnemy = false}; // Not relevant during setup
|
||||
}
|
||||
|
||||
// Determine proximity (≤4 hex distance) - applies to both attackers and defenders
|
||||
bool isClose = false;
|
||||
const auto *units = state->units();
|
||||
|
||||
for (size_t i = 0; i < units->size() && !isClose; ++i) {
|
||||
const auto *myUnit = units->Get(static_cast<unsigned int>(i));
|
||||
if (myUnit->player_id() != playerId) continue;
|
||||
|
||||
const auto &myCoords = myUnit->location();
|
||||
// Skip units that haven't been placed on the map yet
|
||||
if (myCoords.row() == -1) continue;
|
||||
|
||||
const Cube myCube = OffsetToCube(myCoords);
|
||||
|
||||
// Check distance to enemy units
|
||||
for (size_t j = 0; j < units->size(); ++j) {
|
||||
const auto *enemyUnit = units->Get(static_cast<unsigned int>(j));
|
||||
if (enemyUnit->player_id() == playerId) continue;
|
||||
|
||||
const auto &enemyCoords = enemyUnit->location();
|
||||
// Skip enemy units that haven't been placed on the map yet
|
||||
if (enemyCoords.row() == -1) continue;
|
||||
|
||||
const Cube enemyCube = OffsetToCube(enemyCoords);
|
||||
|
||||
if (const int hexDistance = CubeDistance(myCube, enemyCube); hexDistance <= 4) {
|
||||
isClose = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check distance to castles
|
||||
if (!isClose) {
|
||||
for (const auto &castleCoord : castleCoords) {
|
||||
const Cube castleCube = OffsetToCube(castleCoord);
|
||||
|
||||
if (const int hexDistance = CubeDistance(myCube, castleCube); hexDistance <= 4) {
|
||||
isClose = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get time budget from settings - dynamic based on number of commands
|
||||
// Dynamic budget: msPerCommand × numCommands
|
||||
const double msPerCommand =
|
||||
isClose ? settingsGetter.Backing().lookahead_time_budget_per_command_close_ms()
|
||||
: settingsGetter.Backing().lookahead_time_budget_per_command_far_ms();
|
||||
const double budgetMs = msPerCommand * static_cast<double>(numCommands);
|
||||
|
||||
// Clamp to reasonable bounds: 200ms minimum, maxBudgetMs maximum
|
||||
const auto clampedBudgetMs = std::clamp(budgetMs, 200.0, maxBudgetMs);
|
||||
const auto remainingBudget = std::chrono::milliseconds(static_cast<int64_t>(clampedBudgetMs));
|
||||
|
||||
// TEMPORARY DEBUG OUTPUT
|
||||
printf("[DEBUG CalculateTimeBudget] numCommands=%zu, msPerCommand=%.2f, budgetMs=%.2f, "
|
||||
"clampedBudgetMs=%.2f, isClose=%d\n",
|
||||
numCommands,
|
||||
msPerCommand,
|
||||
budgetMs,
|
||||
clampedBudgetMs,
|
||||
isClose);
|
||||
|
||||
// Get minimum depth requirement
|
||||
const size_t minDepth = settingsGetter.Backing().min_lookahead_turns();
|
||||
|
||||
return AITimeBudget{
|
||||
.remainingBudget = remainingBudget,
|
||||
.minDepthRequired = minDepth,
|
||||
.isCloseToEnemy = isClose};
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
@@ -1,49 +0,0 @@
|
||||
//
|
||||
// Created by Dan Crosby on 07/04/25.
|
||||
//
|
||||
|
||||
#ifndef EAGLE0_AITIMEBUDGET_HPP
|
||||
#define EAGLE0_AITIMEBUDGET_HPP
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <memory>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
|
||||
|
||||
namespace shardok {
|
||||
|
||||
// Forward declarations
|
||||
class GameSettings;
|
||||
using GameSettingsSPtr = std::shared_ptr<GameSettings>;
|
||||
|
||||
// RAII counter for tracking concurrent AI command evaluations
|
||||
class AIEvaluationCounter {
|
||||
static std::atomic<int> activeCount;
|
||||
|
||||
public:
|
||||
AIEvaluationCounter();
|
||||
~AIEvaluationCounter();
|
||||
static int GetCurrentCount();
|
||||
};
|
||||
|
||||
// Configuration structure for iterative deepening time budget
|
||||
struct AITimeBudget {
|
||||
std::chrono::milliseconds remainingBudget; // Time budget remaining (decremented as used)
|
||||
size_t minDepthRequired; // Minimum depth from minLookaheadTurns
|
||||
bool isCloseToEnemy; // Proximity flag for budget selection
|
||||
};
|
||||
|
||||
// Calculate time budget based on proximity to enemies and castles
|
||||
// Time budget is calculated dynamically based on number of available commands:
|
||||
// budget = msPerCommand × numCommands (clamped to 200-5000ms)
|
||||
auto CalculateTimeBudget(
|
||||
PlayerId playerId,
|
||||
const GameSettingsSPtr &settings,
|
||||
const GameStateW &state,
|
||||
size_t numCommands) -> AITimeBudget;
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif // EAGLE0_AITIMEBUDGET_HPP
|
||||
@@ -5,7 +5,6 @@
|
||||
#include "AIUnitScoreCalculator.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
|
||||
#include "AIAttackLocations.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
|
||||
@@ -17,10 +16,9 @@ using std::end;
|
||||
using std::shared_ptr;
|
||||
|
||||
constexpr double kProfessionValue = 200;
|
||||
constexpr double kVigorScoreMultiplier = 5.0;
|
||||
constexpr double kCastleMultiplierBonus = 1.0;
|
||||
constexpr double kOnFireMultiplier = 0.25;
|
||||
constexpr double kAdjacentFireMultiplier = 0.80;
|
||||
constexpr double kAdjacentFireMultiplier = 0.99;
|
||||
constexpr double kOnIceMultiplier = 0.25;
|
||||
constexpr double kMeteorStartInRangeValue = 50;
|
||||
constexpr double kMeteorDirectTargetingEnemy = 2;
|
||||
@@ -64,8 +62,7 @@ auto ContextFreeUnitValue(const Unit *unit) -> ScoreValue {
|
||||
4.0;
|
||||
}
|
||||
|
||||
const double vigorValue =
|
||||
unit->has_attached_hero() ? unit->attached_hero().vigor() * kVigorScoreMultiplier : 0.0;
|
||||
const double vigorValue = unit->has_attached_hero() ? unit->attached_hero().vigor() : 0.0;
|
||||
|
||||
double battalionTypeMultiplier = 1.0;
|
||||
switch (unit->battalion().type()) {
|
||||
@@ -91,8 +88,8 @@ auto ContextFreeUnitValue(const Unit *unit) -> ScoreValue {
|
||||
break;
|
||||
}
|
||||
|
||||
const double battalionValue = battalionTypeMultiplier * (1.0 + armament / 100.0) *
|
||||
(1.0 + training / 100.0) * (0.5 + morale / 100.0) *
|
||||
const double battalionValue = battalionTypeMultiplier * (0.5 + armament / 100.0) *
|
||||
(0.5 + training / 100.0) * (0.5 + morale / 100.0) *
|
||||
unit->battalion().size();
|
||||
|
||||
const double heroValue =
|
||||
@@ -101,7 +98,7 @@ auto ContextFreeUnitValue(const Unit *unit) -> ScoreValue {
|
||||
return battalionValue + heroValue;
|
||||
}
|
||||
|
||||
auto archeryValue(const Unit * /*unit*/) -> double {
|
||||
auto archeryValue(const Unit *unit) -> double {
|
||||
// TODO: make this depend on the value of the targets
|
||||
return kArcheryPossibleValue;
|
||||
}
|
||||
@@ -116,7 +113,7 @@ auto reduceValue(const Unit *unit, const Terrain *unitTerrain) -> double {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
auto fearValue(const Unit * /*unit*/) -> double {
|
||||
auto fearValue(const Unit *unit) -> double {
|
||||
// TODO: make this depend on the value of the targets
|
||||
return kFearPossibleValue;
|
||||
}
|
||||
@@ -336,9 +333,8 @@ auto UnitValue(
|
||||
const int roundsRemaining,
|
||||
const AttackLocations &locationsThisSideCanAttackFrom,
|
||||
const CoordsSet &locationsInDangerFromEnemy,
|
||||
const ActionPointDistances *distances,
|
||||
int meteorRange,
|
||||
double meteorCastVigorCost) -> ScoreValue {
|
||||
const std::shared_ptr<ActionPointDistances> &distances,
|
||||
const SettingsGetter &settings) -> ScoreValue {
|
||||
const auto &location = unit->location();
|
||||
if (location.row() < 0) return 0; // unplaced unit
|
||||
|
||||
@@ -346,8 +342,7 @@ auto UnitValue(
|
||||
unit->battalion().type() == net::eagle0::shardok::storage::fb::BattalionTypeId_UNDEAD;
|
||||
|
||||
const int coordsIndex = location.row() * map->column_count() + location.column();
|
||||
const auto *terrain = map->terrain()->Get(coordsIndex);
|
||||
|
||||
const auto &terrain = map->terrain()->Get(coordsIndex);
|
||||
double castleMultiplier = 1.0;
|
||||
// Only give a multiplier for being in a castle if the castle is useful, and the unit is not
|
||||
// undead
|
||||
@@ -357,12 +352,14 @@ auto UnitValue(
|
||||
kCastleMultiplierBonus * (terrain->modifier().castle().integrity() + 25) / 100.0;
|
||||
}
|
||||
double onFireMultiplier = 1.0;
|
||||
if (terrain->modifier().fire().present()) { onFireMultiplier *= kOnFireMultiplier; }
|
||||
if (terrain->modifier().fire().present() && (isAttacker || attackerWantsCastles)) {
|
||||
onFireMultiplier *= kOnFireMultiplier;
|
||||
}
|
||||
{
|
||||
for (const auto adjacentCoords = HexMapUtils::GetAdjacentCoords(map, location);
|
||||
const auto &c : adjacentCoords) {
|
||||
if (const auto *adjTerrain = GetTerrain(map, c);
|
||||
adjTerrain && adjTerrain->modifier().fire().present()) {
|
||||
if (const auto &adjTerrain = GetTerrain(map, c);
|
||||
adjTerrain->modifier().fire().present()) {
|
||||
onFireMultiplier *= kAdjacentFireMultiplier;
|
||||
}
|
||||
}
|
||||
@@ -381,8 +378,8 @@ auto UnitValue(
|
||||
roundsRemaining,
|
||||
attackerUnits,
|
||||
defenderUnits,
|
||||
meteorRange,
|
||||
meteorCastVigorCost);
|
||||
settings.Backing().meteor_range(),
|
||||
settings.Backing().meteor_cast_vigor_cost());
|
||||
|
||||
// scouting values
|
||||
// attack range
|
||||
@@ -417,7 +414,7 @@ auto UnitValue(
|
||||
if (const auto commandingUnitId = unit->commanding_unit_id(); commandingUnitId != -1) {
|
||||
const Unit *commandingUnit = nullptr;
|
||||
for (const Unit *attackerUnit : attackerUnits) {
|
||||
if (attackerUnit && attackerUnit->unit_id() == commandingUnitId) {
|
||||
if (attackerUnit->unit_id() == commandingUnitId) {
|
||||
commandingUnit = attackerUnit;
|
||||
break;
|
||||
}
|
||||
@@ -425,7 +422,7 @@ auto UnitValue(
|
||||
|
||||
if (commandingUnit == nullptr) {
|
||||
for (const Unit *defenderUnit : defenderUnits) {
|
||||
if (defenderUnit && defenderUnit->unit_id() == commandingUnitId) {
|
||||
if (defenderUnit->unit_id() == commandingUnitId) {
|
||||
commandingUnit = defenderUnit;
|
||||
break;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user