Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-27 18:03:58 -07:00
781 changed files with 28016 additions and 71983 deletions
+2 -9
View File
@@ -26,17 +26,10 @@ common --host_cxxopt="--std=c++23"
common --javacopt="-Xlint:-options"
# suppress warnings due to https://developer.apple.com/forums/thread/733317
# Use host_linkopt for macOS-specific flags to avoid passing them to Linux cross-compilation
common:macos --host_linkopt=-Wl,-no_warn_duplicate_libraries
# Fix Xcode version caching issue - avoids need for `bazel clean --expunge` after Xcode updates
common:macos --repo_env=DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
common --linkopt=-Wl
common:macos --linkopt=-Wl,-no_warn_duplicate_libraries
common --java_language_version=17
common --java_runtime_version=remotejdk_17
common --tool_java_language_version=17
common --tool_java_runtime_version=remotejdk_17
# Workspace status for build stamping (git commit, timestamp)
common --workspace_status_command=tools/workspace_status.sh
common --stamp
-3
View File
@@ -6,7 +6,4 @@
*.bytes filter=lfs diff=lfs merge=lfs -text
*.psd filter=lfs diff=lfs merge=lfs -text
*.ttf filter=lfs diff=lfs merge=lfs -text
# Exclude pre-existing font files that were committed as blobs (not LFS pointers)
src/main/csharp/**/GUI[[:space:]]Pro[[:space:]]Kit*/**/*.ttf !filter !diff !merge
src/main/csharp/**/Modern[[:space:]]UI[[:space:]]Pack/**/*.ttf !filter !diff !merge
*.herodata filter=lfs diff=lfs merge=lfs -text
+1 -45
View File
@@ -34,54 +34,10 @@ jobs:
with:
lfs: false
- name: Run tests
id: test
continue-on-error: true
run: bazel test --build_event_json_file=test.json //src/test/... //src/main/go/...
- name: Collect failed test logs
if: always()
run: |
# Remove any existing failed_test_logs directory and create fresh
rm -rf failed_test_logs
mkdir -p failed_test_logs
# Extract failed test targets from test.json and copy their logs
# The test.json is in JSONL format - one JSON object per line
# We look for lines with testResult that have a status other than PASSED
if [ -f test.json ]; then
grep '"testResult"' test.json | \
grep '"status"' | \
grep -v '"status":"PASSED"' | \
grep -o '"label":"[^"]*"' | \
cut -d'"' -f4 | \
sort -u | \
while read target; do
# Convert target like //src/test/cpp/...:test_name to path
log_path=$(echo "$target" | sed 's|^//||' | sed 's|:|/|')
if [ -f "bazel-testlogs/$log_path/test.log" ]; then
log_name=$(echo "$log_path" | tr '/' '_')
if cp "bazel-testlogs/$log_path/test.log" "failed_test_logs/${log_name}.log"; then
echo "Collected log for failed test: $target"
else
echo "Error: Failed to copy log for $target"
fi
fi
done
fi
# List what we collected
echo "Collected logs:"
ls -lh failed_test_logs/ 2>/dev/null || echo "No logs collected"
- name: Archive test results
if: always()
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: test.json
path: test.json
- name: Archive failed test logs
if: always()
uses: actions/upload-artifact@v4
with:
name: failed-test-logs
path: failed_test_logs/
if-no-files-found: ignore
- name: Fail if tests failed
if: steps.test.outcome == 'failure'
run: exit 1
-66
View File
@@ -1,66 +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: |
if ! command -v aws &> /dev/null; then
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip -q awscliv2.zip
sudo ./aws/install
fi
- name: Upload to DigitalOcean Spaces
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SECRET_KEY }}
run: |
# Upload sysroot tarball to DO Spaces (using eagle0-windows bucket, same as other workflows)
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.tar.xz \
s3://eagle0-windows/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-windows/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-windows.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-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
echo ")"
@@ -1,24 +0,0 @@
name: Deploy OAuth Relay Worker
on:
push:
branches:
- main
paths:
- 'cloudflare/oauth-relay/**'
workflow_dispatch: # Allow manual trigger
jobs:
deploy:
runs-on: ubuntu-latest
name: Deploy to Cloudflare Workers
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy Worker
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
workingDirectory: cloudflare/oauth-relay
-545
View File
@@ -1,545 +0,0 @@
name: Docker Build and Push
on:
push:
branches: [ "main" ]
paths:
- 'src/main/cpp/**'
- 'src/main/go/**'
- 'src/main/scala/**'
- 'src/main/protobuf/**'
- 'src/main/resources/**'
- 'ci/BUILD.bazel'
- 'MODULE.bazel'
- 'docker-compose.prod.yml'
- 'nginx/**'
- '.github/workflows/docker_build.yml'
workflow_dispatch:
inputs:
push_images:
description: 'Push images to container registry'
required: true
default: 'false'
type: boolean
permissions:
contents: read
jobs:
build-eagle:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-eagle.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Eagle Docker image
id: build-eagle
run: |
set -ex
bazel build --platforms=//:linux_x86_64 //ci:eagle_server_image
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
# Also set for current directory in case Bazel uses different home
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push Eagle image to DO registry
id: push-eagle
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
# Use cross-compiled image path from build step
EAGLE_IMAGE="${{ steps.build-eagle.outputs.image_path }}"
echo "Using Eagle image: $EAGLE_IMAGE"
if [ -z "$EAGLE_IMAGE" ] || [ ! -d "$EAGLE_IMAGE" ]; then
echo "ERROR: Eagle image not found at: $EAGLE_IMAGE"
exit 1
fi
# Debug: show OCI layout contents
echo "=== OCI Layout Contents ==="
cat "$EAGLE_IMAGE/index.json"
echo ""
echo "=== Blobs ==="
ls -la "$EAGLE_IMAGE/blobs/sha256/" | head -20
# Verify OCI layout consistency before pushing
echo "=== Verifying OCI layout consistency ==="
for digest in $(cat "$EAGLE_IMAGE/index.json" | grep -o '"sha256:[^"]*"' | tr -d '"'); do
blob_path="$EAGLE_IMAGE/blobs/${digest/://}"
if [ ! -f "$blob_path" ]; then
echo "ERROR: Blob not found: $blob_path"
exit 1
fi
actual_digest="sha256:$(shasum -a 256 "$blob_path" | cut -d' ' -f1)"
if [ "$digest" != "$actual_digest" ]; then
echo "ERROR: Digest mismatch for $blob_path"
echo " Index says: $digest"
echo " Actual: $actual_digest"
exit 1
fi
echo "✓ Verified: $digest"
done
# Build the push target to get crane in runfiles
bazel build //ci:eagle_server_push
# Use crane directly for push (avoids OCI->Docker digest mismatch)
CRANE="bazel-bin/ci/push_eagle_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
echo "Using crane: $CRANE"
# Push with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/eagle-server:${GIT_SHA}"
echo "Pushing eagle image: $IMAGE_TAG"
$CRANE push "$EAGLE_IMAGE" "$IMAGE_TAG"
# Verify push by checking what's in the registry
echo "=== Verifying push ==="
$CRANE manifest "$IMAGE_TAG" | head -50
PUSHED_DIGEST=$($CRANE digest "$IMAGE_TAG")
echo "Registry reports digest: $PUSHED_DIGEST"
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience (but deploy won't use it)
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest"
build-shardok:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Shardok binary (cross-compile for Linux)
run: |
set -ex
# Step 1: Build JUST the binary with cross-compilation
# We need --extra_toolchains to force the Linux toolchain to be used
# because toolchains_llvm registers with dev_dependency=True
echo "=== Building shardok-server binary for linux-x86_64 ==="
bazel build \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:all \
//src/main/cpp/net/eagle0/shardok:shardok-server
# Step 2: Check the binary directly from bazel-bin
# bazel-bin is a symlink that points to the correct output directory
LINUX_BIN="bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server"
echo "=== Checking binary at: $LINUX_BIN ==="
if [ ! -f "$LINUX_BIN" ]; then
echo "ERROR: Binary not found at $LINUX_BIN"
exit 1
fi
# Debug: show what bazel-bin points to
echo "bazel-bin symlink target: $(readlink bazel-bin || echo 'not a symlink')"
# Step 3: Verify it's ELF (Linux) not Mach-O (macOS)
echo "=== Verifying binary format ==="
MAGIC=$(head -c 4 "$LINUX_BIN" | xxd -p)
echo "Binary magic bytes: $MAGIC"
if [ "$MAGIC" = "7f454c46" ]; then
echo "SUCCESS: Binary is ELF format (Linux)"
elif [ "$MAGIC" = "cfaeedfe" ] || [ "$MAGIC" = "cffaedfe" ]; then
echo "ERROR: Binary is Mach-O format (macOS) - cross-compilation failed!"
echo ""
echo "Debug info:"
echo "- bazel-bin points to: $(readlink bazel-bin)"
file "$LINUX_BIN" || true
exit 1
else
echo "WARNING: Unknown binary format: $MAGIC"
file "$LINUX_BIN" || true
fi
- name: Build Shardok Docker image
id: build-shardok
run: |
set -ex
# Build the OCI image with cross-compilation flags
bazel build \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:all \
//ci:shardok_server_image
# The image is output to bazel-bin which is a symlink.
# Resolve it now before any other bazel commands change where it points.
IMAGE_PATH=$(readlink -f bazel-bin/ci/shardok_server_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
# Verify the binary inside the tar layer is ELF
echo "=== Verifying binary in image tar ==="
BINARY_TAR="bazel-bin/ci/shardok_binary_layer.tar"
if [ -f "$BINARY_TAR" ]; then
echo "Checking binary in $BINARY_TAR"
# Extract just the first 4 bytes of the binary from the tar
MAGIC=$(tar -xOf "$BINARY_TAR" app/shardok-server 2>/dev/null | head -c 4 | xxd -p)
echo "Binary magic in tar: $MAGIC"
if [ "$MAGIC" = "7f454c46" ]; then
echo "SUCCESS: Binary in tar is ELF format (Linux)"
else
echo "ERROR: Binary in tar is NOT ELF format!"
echo "This means pkg_tar is packaging the wrong binary."
exit 1
fi
else
echo "WARNING: Could not find $BINARY_TAR"
fi
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DO_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
# Also set for current directory in case Bazel uses different home
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push Shardok image to DO registry
id: push-shardok
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
# Use cross-compiled image path from build step
CROSS_IMAGE="${{ steps.build-shardok.outputs.image_path }}"
echo "Using cross-compiled image: $CROSS_IMAGE"
if [ -z "$CROSS_IMAGE" ] || [ ! -d "$CROSS_IMAGE" ]; then
echo "ERROR: Cross-compiled image not found at: $CROSS_IMAGE"
exit 1
fi
# Get crane from Eagle push target (which doesn't need cross-compilation)
# This gives us a macOS crane binary we can actually run.
# We can't build shardok_server_push with platform flags because it would
# download a Linux crane that can't run on macOS.
bazel build //ci:eagle_server_push
# Find the Darwin crane binary (may be a symlink)
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
if [ -z "$CRANE" ]; then
# Fallback to any crane
CRANE=$(find "$RUNFILES" -name crane 2>/dev/null | head -1)
fi
if [ -z "$CRANE" ] || [ ! -e "$CRANE" ]; then
echo "ERROR: crane not found. Listing runfiles:"
find "$RUNFILES" -name crane 2>/dev/null || true
exit 1
fi
echo "Using crane: $CRANE"
# Push the cross-compiled image with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/shardok-server:${GIT_SHA}"
echo "Pushing shardok image: $IMAGE_TAG"
$CRANE push "$CROSS_IMAGE" "$IMAGE_TAG"
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience (but deploy won't use it)
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/shardok-server:latest"
build-admin:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-admin.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Admin Server Docker image
id: build-admin
run: |
set -ex
# Build admin server image (Go binary has explicit goos/goarch in BUILD.bazel)
bazel build //ci:admin_server_image
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/admin_server_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push Admin image to DO registry
id: push-admin
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
ADMIN_IMAGE="${{ steps.build-admin.outputs.image_path }}"
echo "Using Admin image: $ADMIN_IMAGE"
if [ -z "$ADMIN_IMAGE" ] || [ ! -d "$ADMIN_IMAGE" ]; then
echo "ERROR: Admin image not found at: $ADMIN_IMAGE"
exit 1
fi
# Build the push target to get crane in runfiles
bazel build //ci:admin_server_push
# Use crane directly for push
CRANE="bazel-bin/ci/push_admin_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
echo "Using crane: $CRANE"
# Push with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/admin-server:${GIT_SHA}"
echo "Pushing admin image: $IMAGE_TAG"
$CRANE push "$ADMIN_IMAGE" "$IMAGE_TAG"
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/admin-server:latest"
build-jfr-sidecar:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-jfr-sidecar.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build JFR Sidecar Docker image
id: build-jfr-sidecar
run: |
set -ex
# Build JFR sidecar image (Go binary has explicit goos/goarch in BUILD.bazel)
bazel build //ci:jfr_sidecar_image
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/jfr_sidecar_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push JFR Sidecar image to DO registry
id: push-jfr-sidecar
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
JFR_IMAGE="${{ steps.build-jfr-sidecar.outputs.image_path }}"
echo "Using JFR Sidecar image: $JFR_IMAGE"
if [ -z "$JFR_IMAGE" ] || [ ! -d "$JFR_IMAGE" ]; then
echo "ERROR: JFR Sidecar image not found at: $JFR_IMAGE"
exit 1
fi
# Build the push target to get crane in runfiles
bazel build //ci:jfr_sidecar_push
# Use crane directly for push
CRANE="bazel-bin/ci/push_jfr_sidecar_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
echo "Using crane: $CRANE"
# Push with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/jfr-sidecar:${GIT_SHA}"
echo "Pushing JFR sidecar image: $IMAGE_TAG"
$CRANE push "$JFR_IMAGE" "$IMAGE_TAG"
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest"
deploy:
runs-on: ubuntu-latest
needs: [build-eagle, build-shardok, build-admin, build-jfr-sidecar]
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
environment: production
env:
EAGLE_IMAGE: ${{ needs.build-eagle.outputs.image_tag }}
SHARDOK_IMAGE: ${{ needs.build-shardok.outputs.image_tag }}
ADMIN_IMAGE: ${{ needs.build-admin.outputs.image_tag }}
JFR_SIDECAR_IMAGE: ${{ needs.build-jfr-sidecar.outputs.image_tag }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GPT_MODEL_NAME: ${{ secrets.GPT_MODEL_NAME }}
EAGLE_ENABLE_S3: ${{ secrets.EAGLE_ENABLE_S3 }}
DO_SPACES_ACCESS_KEY: ${{ secrets.DO_SPACES_ACCESS_KEY }}
DO_SPACES_SECRET_KEY: ${{ secrets.DO_SPACES_SECRET_KEY }}
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }}
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Copy config files to droplet
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
source: "docker-compose.prod.yml,nginx/nginx.conf"
target: "/opt/eagle0"
- name: Deploy to production droplet
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
script_stop: true
envs: EAGLE_IMAGE,SHARDOK_IMAGE,ADMIN_IMAGE,JFR_SIDECAR_IMAGE,OPENAI_API_KEY,GPT_MODEL_NAME,EAGLE_ENABLE_S3,DO_SPACES_ACCESS_KEY,DO_SPACES_SECRET_KEY,JWT_PRIVATE_KEY,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET
script: |
set -x
cd /opt/eagle0
# Write env vars to .env file for docker-compose
rm -f .env 2>/dev/null || true
cat > .env << EOF
EAGLE_IMAGE=${EAGLE_IMAGE}
SHARDOK_IMAGE=${SHARDOK_IMAGE}
ADMIN_IMAGE=${ADMIN_IMAGE}
JFR_SIDECAR_IMAGE=${JFR_SIDECAR_IMAGE}
OPENAI_API_KEY=${OPENAI_API_KEY:-}
GPT_MODEL_NAME=${GPT_MODEL_NAME:-gpt-4o}
EAGLE_ENABLE_S3=${EAGLE_ENABLE_S3:-false}
DO_SPACES_ACCESS_KEY=${DO_SPACES_ACCESS_KEY:-}
DO_SPACES_SECRET_KEY=${DO_SPACES_SECRET_KEY:-}
JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY:-}
DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID:-}
DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET:-}
EOF
chmod 600 .env
# Login to registry
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
# Use exact image tags passed from build jobs (no :latest fallback)
echo "Using images: $EAGLE_IMAGE, $SHARDOK_IMAGE, $ADMIN_IMAGE, $JFR_SIDECAR_IMAGE"
# Use crane to pull images (handles OCI format correctly) then load into Docker
# This avoids digest mismatch from DO registry's OCI->Docker format conversion
echo "Installing crane..."
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
chmod +x crane
# crane uses Docker config for auth
echo "Pulling Eagle image with crane..."
./crane pull "${EAGLE_IMAGE}" eagle.tar || { echo "ERROR: Failed to pull eagle image"; exit 1; }
echo "Loading Eagle image into Docker..."
docker load -i eagle.tar
rm eagle.tar
echo "Pulling Shardok image with crane..."
./crane pull "${SHARDOK_IMAGE}" shardok.tar || { echo "ERROR: Failed to pull shardok image"; exit 1; }
echo "Loading Shardok image into Docker..."
docker load -i shardok.tar
rm shardok.tar
echo "Pulling Admin image with crane..."
./crane pull "${ADMIN_IMAGE}" admin.tar || { echo "ERROR: Failed to pull admin image"; exit 1; }
echo "Loading Admin image into Docker..."
docker load -i admin.tar
rm admin.tar
echo "Pulling JFR Sidecar image with crane..."
./crane pull "${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar || { echo "ERROR: Failed to pull jfr-sidecar image"; exit 1; }
echo "Loading JFR Sidecar image into Docker..."
docker load -i jfr-sidecar.tar
rm jfr-sidecar.tar
rm ./crane
# Also pull other compose images
docker pull nginx:alpine || true
docker pull certbot/certbot || true
echo "All images pulled successfully"
# Force recreate containers to ensure new image is used
docker compose -f docker-compose.prod.yml up -d --force-recreate --remove-orphans
# Restart nginx to pick up new container IPs
# (nginx caches DNS at startup, so it needs restart after eagle/shardok)
docker compose -f docker-compose.prod.yml restart nginx
# Wait for health checks
sleep 10
docker compose -f docker-compose.prod.yml ps
# Verify containers are using correct images
echo "=== Verifying container image tags ==="
docker compose -f docker-compose.prod.yml images
# Cleanup old images
docker image prune -f
+2 -2
View File
@@ -52,14 +52,14 @@ jobs:
- name: Persist Library/
run: ./ci/github_actions/persist_library.sh
- name: Deploy Windows unity
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
if: success() #&& github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: bazel run //src/main/go/net/eagle0/build/unity3d_windows_build_handler:unity3d_windows_build_handler -- "/tmp/eagle0/eagle0WIN" "/tmp/unity_manifest.txt"
- name: Update unified manifest
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
if: success() #&& github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
-1
View File
@@ -37,4 +37,3 @@ scripts/refresh_name_layers/refresh_name_layers.zip
.metals
api_keys.txt
src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/Packages/com.unity.dedicated-server/
+1 -2
View File
@@ -32,9 +32,8 @@ repos:
- id: gazelle
name: gazelle
language: system
entry: ./scripts/pre-commit-gazelle.sh
entry: bazel run //:gazelle
files: '(\.go|\.proto|BUILD\.bazel|BUILD|WORKSPACE|WORKSPACE\.bazel|\.bzl)$'
pass_filenames: false
- repo: local
hooks:
- id: update-action-result-types
-9
View File
@@ -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
-61
View File
@@ -85,16 +85,6 @@ bazel run gazelle # Update Go build files
./scripts/updateActionResultTypes.sh # Update protocol buffer mappings
```
### Pre-Commit Checklist
**MANDATORY: Before running `git commit`, verify:**
1. **If you modified any BUILD.bazel file:** Run `bazel run gazelle` and stage any changes it makes
2. **If you modified C++ or C# files:** Run `clang-format -i` on the modified files
3. **If you modified Scala files:** scalafmt will run automatically via pre-commit hook
The pre-commit hook runs gazelle but only checks if it succeeds - it does NOT verify the BUILD files are in canonical format. The `gazelle_test` will fail if deps are not alphabetically sorted. **Always run gazelle manually after BUILD file changes.**
### Code Formatting
```bash
@@ -216,31 +206,6 @@ to be used for different players or game situations within the same server proce
- Map validation tests ensure game content integrity
- Use `GameSettings_test_utils.cpp` and `ShardokEngineBasedTestData.cpp` for C++ test helpers
### Scala Testing Patterns
**Use `inside()` instead of `asInstanceOf` for type matching in tests:**
Never use `asInstanceOf` in tests. Instead, use ScalaTest's `inside()` pattern for safe type matching:
```scala
// BAD - don't do this
val changedHero = result.changedHeroes.head.asInstanceOf[ChangedHeroC]
changedHero.heroId shouldBe 19
// GOOD - use inside() pattern
import org.scalatest.Inside.inside
inside(result.changedHeroes.head) { case changedHero: ChangedHeroC =>
changedHero.heroId shouldBe 19
changedHero.vigorChange shouldBe StatDelta(17.2)
}
```
The `inside()` pattern:
- Provides better error messages when the type doesn't match
- Is idiomatic ScalaTest
- Works with pattern matching for more complex assertions
## Performance Testing
When making performance-related changes to the AI or engine:
@@ -279,32 +244,6 @@ done
- **Always test performance changes** - what seems like an optimization may sometimes have unexpected overhead or
behavior changes.
## Troubleshooting Scala Build Errors
### MissingType Errors
When you see errors like:
```
dotty.tools.dotc.core.MissingType: Cannot resolve reference to type net.eagle0.eagle.internal.game_state.type.GameState
```
**This is NOT a Scala compiler crash.** This is a missing dependency in BUILD.bazel.
**How to fix:**
1. Identify the missing type from the error message (e.g., `game_state.GameState`)
2. Find the Bazel target that provides this type (e.g., `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`)
3. Add it to the `deps` of the failing target
4. If the type appears in a public method signature, also add it to `exports` so downstream targets can see it
**Common pattern:** When adding a method to a class that takes or returns a proto type, the proto dependency often needs to be added to both `deps` AND `exports`.
### Bazel Clean
**NEVER run `bazel clean` without asking first.** It rarely fixes actual issues and wastes significant rebuild time. The issues that seem like they need `bazel clean` are usually:
- Missing imports in Scala code
- Missing dependencies in BUILD.bazel
- Missing exports for types used in public signatures
## Game Content
**Maps:** `.e0mj` files in `/src/main/resources/net/eagle0/shardok/maps/`
-128
View File
@@ -1,128 +0,0 @@
# Deproto Migration Plan
This document tracks the migration from protobuf types to native Scala models inside the Eagle game engine.
## Architectural Decisions
1. **Keep proto for persistence**: Yes - protobuf is used for persisting game state
2. **Keep proto for Shardok communication**: Yes - protobuf is used for Eagle-Shardok gRPC communication
3. **Use Scala views inside the library**: Yes - use native Scala types like `ProvinceView`, `FactionView`, `HeroView`, etc. within the library code
## Recent Completed Work
### GameState Round-Trip Elimination (PRs #4913, #4914, #4915)
Eliminated wasteful Scala→proto→Scala conversions in the hot path:
1. **LLM Pipeline** (#4913): `LlmRequestWithGameState` now uses Scala `GameState` instead of proto. All ~38 prompt generators updated to use Scala model types (`FactionT`, `HeroT`, `ProvinceT`).
2. **ActionWithResultingState Caching** (#4914): Added `precomputedScalaState: Option[GameState]` to cache Scala state when available, avoiding `fromProto()` conversion in `stateAfter()`.
3. **PostResults Simplification** (#4915): Changed `PostResults.gameState` from proto to `Option[GameState]` (Scala), eliminating `toProto()` calls when creating PostResults.
## Migration Pattern
The codebase follows a **Legacy* pattern** for separating proto-dependent and protoless code:
- **Protoless utilities**: `FactionUtils`, `HeroUtils`, `ProvinceUtils`, `ProvinceDistances`, etc.
- **Proto-dependent utilities**: `LegacyFactionUtils`, `LegacyHeroUtils`, `LegacyProvinceUtils`, `LegacyProvinceDistances`, etc.
When migrating a file:
1. Create a `Legacy*` version containing the proto-dependent methods
2. Keep the original file name for protoless methods
3. Update callers to use the appropriate version based on their context
## Migration Status
### Fully Protoless (no proto imports)
**Utilities:**
- [x] `FactionUtils` - has protoless `ownedNeighbors` method
- [x] `ProvinceDistances` - split into protoless + `LegacyProvinceDistances`
- [x] `SwornBrotherChooser` - fully protoless (removed `bestChoiceProto`)
**Command Selectors (all use native GameState):**
- [x] `AllianceOfferCommandSelector`
- [x] `AlmsCommandSelector`
- [x] `AttackCommandChooser`
- [x] `ExpandCommandSelector`
- [x] `HeroGiftCommandSelector`
- [x] `ImproveCommandSelector`
- [x] `MarchTowardProvinceCommandChooser` - in AI folder, uses native GameState (callers convert)
- [x] `OrganizeCommandSelector`
- [x] `RansomOfferHelpers`
- [x] `SeekMoreLeadersCommandChooser` - in AI folder, uses native GameState
- [x] `TruceOfferCommandSelector`
- [x] `TrustForDiplomacy`
**Quest Command Selectors (all protoless):**
- [x] `AllianceQuestCommandChooser`
- [x] `AlmsAcrossRealmQuestCommandChooser`
- [x] `AlmsToProvinceQuestCommandChooser`
- [x] `DismissSpecificVassalCommandChooser`
- [x] `GiveToHeroesAcrossRealmQuestCommandChooser`
- [x] `GiveToHeroesInProvinceQuestCommandChooser`
- [x] `ImproveQuestCommandChooser`
- [x] `QuestCommandChooser`
- [x] `TruceCountQuestCommandChooser`
- [x] `TruceWithFactionQuestCommandChooser`
### Fully Protoless
- [x] `AIClientUtils` - has protoless overloads (`takenHeroIdsForMarchTowardFocus`, `mostPowerfulHeroes`)
- [x] `ProvinceGoldSurplusCalculator` - fully protoless (callers use converters)
- [x] `HeroSelector` - fully protoless (removed dead `minimallyFatiguedHeroesProto`)
### Blocked (still uses proto GameState)
- [ ] `CommandChoiceHelpers` - main target, uses proto GameState extensively
- Depends on many Legacy* utils
- Central hub called by many command selectors
- [ ] `AttackDecisionCommandChooser` - uses proto GameState, converts internally
- [ ] `CommandChooser` - trait uses proto GameState in signature
- [ ] `FulfillQuestsCommandSelector` - takes proto, converts to native immediately
- Called by `MidGameAIClient` which uses proto GameState
## Next Steps
### Phase 1: CommandChoiceHelpers Migration
The main blocker is `CommandChoiceHelpers.scala` which uses proto `GameState` extensively. Strategy:
1. **Add Scala overloads** to `CommandChoiceHelpers` methods that currently take proto GameState
2. **Update internal helpers** to use Scala types where possible
3. **Migrate callers incrementally** - command selectors that are already protoless can switch to Scala overloads
### Phase 2: CommandChooser Trait
Once CommandChoiceHelpers is protoless:
1. Add Scala `GameState` overload to `CommandChooser.choose()` method
2. Update implementations (`AttackDecisionCommandChooser`, etc.) to use Scala internally
3. Eventually deprecate proto overloads
### Phase 3: MidGameAIClient
The top-level AI client still uses proto GameState. Once lower layers are protoless:
1. Convert `MidGameAIClient` to use Scala GameState internally
2. Only convert at the boundary when receiving from/sending to gRPC
## Key Files
### Protoless Model Types
- `src/main/scala/net/eagle0/eagle/model/state/game_state/GameState.scala` - native Scala GameState
- `src/main/scala/net/eagle0/eagle/model/state/province/ProvinceView.scala` - province view type
- `src/main/scala/net/eagle0/eagle/model/state/faction/FactionView.scala` - faction view type
- `src/main/scala/net/eagle0/eagle/model/state/hero/HeroView.scala` - hero view type
### Proto Converters
- `src/main/scala/net/eagle0/eagle/model/proto_converters/game_state/` - converts between proto and Scala types
## Notes
- The AI client code (`src/main/scala/net/eagle0/eagle/ai/`) currently uses proto types extensively
- `PerformUnaffiliatedHeroesAction` already uses protoless `GameState`
- Migration should proceed incrementally: utilities first, then higher-level selectors/choosers
+463
View File
@@ -0,0 +1,463 @@
# MCTS Transposition Table Enhancement: Caching State Transitions
## Executive Summary
**Goal:** Avoid redundant `PostCommand` calls by caching state transitions (stateHash, actionIndex, roll) → nextStateHash.
**Key Findings:**
1. ✅ MCTS already has thread-local cache (`legalActionsCache_`) with 70-90% hit rates
2.**DETERMINISM SOLUTION:** Cache (action, roll) tuples instead of just action
- Initial implementation: always use roll=50
- Future-proof: supports chance nodes with multiple rolls (10, 30, 50, 70, 90)
- Architecturally solves the non-determinism problem
3. ✅ Enhancement is straightforward: add `actionResults` map to existing `LegalActionsCache` struct
4. ✅ Expected benefit: Eliminate PostCommand overhead on cache hits (could save 5-15 microseconds per hit)
**Recommendation:** Implement using (action, roll) tuple keys. Initial implementation uses roll=50 for all transitions, but data structure supports future expansion to chance nodes.
---
## Current Implementation
**IMPORTANT:** MCTS does NOT use the TranspositionTable.hpp/cpp files. Those are for IterativeDeepening AI.
MCTS uses a thread-local cache in `ShardokGameEngine` (src/main/cpp/net/eagle0/shardok/ai/mcts/adapters/ShardokGameEngine.cpp):
```cpp
// Thread-local cache (line 20-21)
thread_local gtl::flat_hash_map<uint64_t, ShardokGameEngine::LegalActionsCache> legalActionsCache_;
struct LegalActionsCache {
std::shared_ptr<ShardokEngine> engine; // Cached engine with populated command cache
std::vector<size_t> filteredIndices; // Filtered action indices
};
```
**Current cache mapping:**
```
stateHash -> LegalActionsCache {
ShardokEngine engine, // Engine with command cache populated
vector<size_t> filteredIndices // Filtered action indices
}
```
**Purpose:** Avoid recalculating GetAvailableCommands and FilterCommands for states we've seen before.
**Performance:** Already achieving ~70-90% hit rates in typical searches (see reportCacheStatistics() output).
## Proposed Enhancement
Extend the `LegalActionsCache` struct to also cache state transitions using (action, roll) tuples:
```cpp
// Key for transition cache: (actionIndex, roll)
struct TransitionKey {
size_t actionIndex;
int roll;
bool operator==(const TransitionKey& other) const {
return actionIndex == other.actionIndex && roll == other.roll;
}
};
// Hash function for TransitionKey
struct TransitionKeyHash {
size_t operator()(const TransitionKey& key) const {
return std::hash<size_t>{}(key.actionIndex) ^ (std::hash<int>{}(key.roll) << 1);
}
};
struct LegalActionsCache {
std::shared_ptr<ShardokEngine> engine; // Existing: cached engine
std::vector<size_t> filteredIndices; // Existing: filtered action indices
gtl::flat_hash_map<TransitionKey, uint64_t, TransitionKeyHash> actionResults; // NEW: (action, roll) -> next_state_hash
};
```
**Purpose:** Avoid re-applying actions (PostCommand calls) for (state, action, roll) tuples we've already evaluated.
**Why (action, roll) tuples?**
- Handles determinism explicitly: different rolls produce different next states
- Initial implementation uses roll=50 for all transitions
- Future-proof: supports chance nodes with multiple roll values (10, 30, 50, 70, 90)
- Architecturally cleaner than requiring PostCommand to always use the same roll
**Location:** Modify `ShardokGameEngine::applyAction()` (line 50-99 in ShardokGameEngine.cpp)
## How It Works
### During MCTS Exploration/Simulation:
1. **Before applying an action:**
- Look up current state hash in cache
- If found, check if `actionResults` contains the (action_index, roll) tuple we want to apply
- If yes, retrieve the next state's hash from the map
- Look up that hash in the cache to get the next state's engine directly
- **Skip PostCommand entirely** - we already know the result!
2. **When applying a new action:**
- Apply action normally via `engine.PostCommand(playerId, actionIndex, roll)`
- Hash the resulting state
- Store the mapping: `actionResults[{action_index, roll}] = next_state_hash`
- Store the next state in the cache (if not already present)
### Example Flow:
```cpp
// Current state hash: 0xABCD
// Want to apply action index 5 with roll 50
const int roll = 50; // Fixed roll for initial implementation
auto it = legalActionsCache_.find(0xABCD);
if (it != legalActionsCache_.end()) {
TransitionKey key{5, roll};
if (auto resIt = it->second.actionResults.find(key); resIt != it->second.actionResults.end()) {
// We've applied this (action, roll) before!
uint64_t nextHash = resIt->second;
if (auto nextIt = legalActionsCache_.find(nextHash); nextIt != legalActionsCache_.end()) {
// We have the complete next state cached
// Clone the cached engine and return - No PostCommand needed!
return createStateFromCachedEngine(nextIt->second.engine);
}
}
}
// Haven't seen this (state, action, roll) tuple before, apply normally
engine.PostCommand(playerId, 5, roll);
uint64_t nextHash = HashGameState(engine.GetCurrentGameState());
legalActionsCache_[0xABCD].actionResults[{5, roll}] = nextHash;
```
## Benefits
1. **Eliminates redundant PostCommand calls**
- PostCommand involves creating new GameStateW, potentially allocating memory
- Combat resolution, unit updates, state validation all skipped when cached
2. **Particularly valuable for MCTS**
- MCTS revisits states many times during tree search
- Same state-action pairs explored in multiple simulations
- Deeper trees mean more opportunities for cache hits
3. **Compounds with existing optimizations**
- Already caching score calculations
- Already caching available commands
- Now also caching state transitions
- All three together significantly reduce per-simulation cost
## Potential Issues & Solutions
### 1. Memory Usage
**Issue:** Storing (action, roll)->hash mappings for every visited state could consume significant memory.
**Mitigation:**
- Only store recently used entries (already done - thread-local cache per search)
- Cache is automatically cleared between searches
- Monitor memory usage in production
**Analysis:**
- Each cache entry: `TransitionKey{size_t actionIndex, int roll}` + `uint64_t nextHash`
- Size: ~24 bytes per entry (8 + 4 + 8, plus hash map overhead)
- For 10,000 states × 10 actions × 1 roll = 100,000 entries ≈ 2.4 MB
- With chance nodes (5 rolls per action): 10,000 × 10 × 5 = 500,000 entries ≈ 12 MB
- **Reasonable** for modern systems, especially since it's thread-local and cleared per search
### 2. Determinism Requirements
**Issue:** PostCommand results depend on the roll parameter, which affects combat outcomes and random events.
**ARCHITECTURAL SOLUTION:** Cache (action, roll) tuples instead of just actions!
```cpp
// Cache key includes BOTH action and roll
TransitionKey key{actionIndex, roll};
actionResults[key] = nextStateHash;
```
**Why this solves the problem:**
- Each (action, roll) combination gets its own cache entry
- If we call PostCommand(5, 50), we cache the result for (5, 50)
- If we later call PostCommand(5, 70), it's a different cache key - no collision!
- No need to enforce determinism at the PostCommand level
- Data structure naturally supports multiple rolls per action
**Initial Implementation:**
- Use fixed roll=50 for all transitions (matching IterativeDeepening)
- All cache entries will have roll=50
- Simple and deterministic
**Future Enhancement:**
- Implement chance nodes by exploring multiple rolls (10, 30, 50, 70, 90)
- Each roll becomes a separate child in the MCTS tree
- Cache naturally handles this: (action=5, roll=10), (action=5, roll=50), (action=5, roll=90) are distinct
- This models uncertainty without requiring code changes to the cache structure
**Required Changes:**
1. **Change PostCommand call in `applyAction()` (line 82):**
```cpp
// OLD:
engine->PostCommand(currentPlayer, shardokAction->getIndex(), nullptr);
// NEW:
const int roll = 50; // Fixed roll for initial implementation
engine->PostCommand(currentPlayer, shardokAction->getIndex(), roll);
```
2. **Change PostCommand call in `applyActionMutable()` (line 133):**
```cpp
// Same change - use roll=50
```
3. **Store the roll used when caching:**
```cpp
TransitionKey key{actionIndex, roll};
legalActionsCache_[currentStateHash].actionResults[key] = nextStateHash;
```
**Status:** ✅ SOLVED ARCHITECTURALLY - No determinism issues with this design.
### 3. Hash Collisions
**Issue:** Two different states might hash to the same value.
**Current situation:** Already a risk with existing transposition table.
**Mitigation:**
- Use 64-bit hashes (current implementation) - collision probability very low
- Could add verification: store state size/checksum alongside hash
- Could add debug mode that does full state comparison
### 4. Action Index Stability
**Issue:** Action indices must be stable (same action always has same index for a given state).
**Verification:**
- ShardokEngine::GetAvailableCommandProtos() must return actions in deterministic order
- Need to verify this is true
- If not, would need to hash actions themselves, not just use indices
**Risk:** LOW - game engine likely returns actions in consistent order
### 5. State Ownership & Copying
**Issue:** GameStateW contains FlatBufferBuilder, careful with copying/references.
**Solution:**
- TranspositionTable already stores complete ShardokEngine (which contains GameStateW)
- No additional complexity beyond existing implementation
- Just need to ensure we're cloning engines appropriately
## Implementation Plan
### Phase 1: Define TransitionKey and Extend Data Structure
**File:** `src/main/cpp/net/eagle0/shardok/ai/mcts/adapters/ShardokGameEngine.hpp`
1. **Add TransitionKey struct (before LegalActionsCache):**
```cpp
// Key for transition cache: (actionIndex, roll)
struct TransitionKey {
size_t actionIndex;
int roll;
bool operator==(const TransitionKey& other) const {
return actionIndex == other.actionIndex && roll == other.roll;
}
};
// Hash function for TransitionKey
struct TransitionKeyHash {
size_t operator()(const TransitionKey& key) const {
return std::hash<size_t>{}(key.actionIndex) ^ (std::hash<int>{}(key.roll) << 1);
}
};
```
2. **Update `LegalActionsCache` struct:**
```cpp
struct LegalActionsCache {
std::shared_ptr<ShardokEngine> engine;
std::vector<size_t> filteredIndices;
gtl::flat_hash_map<TransitionKey, uint64_t, TransitionKeyHash> actionResults; // NEW
};
```
3. **Add cache statistics fields:**
```cpp
// Add to class members:
thread_local static uint64_t transitionCacheHits_;
thread_local static uint64_t transitionCacheMisses_;
```
### Phase 2: Integrate with applyAction
**File:** `src/main/cpp/net/eagle0/shardok/ai/mcts/adapters/ShardokGameEngine.cpp`
Modify `ShardokGameEngine::applyAction()` (lines 50-99):
```cpp
std::unique_ptr<MCTSGameState> ShardokGameEngine::applyAction(
const MCTSGameState& state,
const MCTSAction& action) const {
const auto* shardokState = dynamic_cast<const ShardokGameState*>(&state);
const auto* shardokAction = dynamic_cast<const ShardokAction*>(&action);
if (!shardokState || !shardokAction) { return nullptr; }
const uint64_t currentStateHash = shardokState->hash();
const size_t actionIndex = shardokAction->getIndex();
const int roll = 50; // Fixed roll for initial implementation
// NEW: Check if we've already applied this (action, roll) to this state
TransitionKey key{actionIndex, roll};
if (auto it = legalActionsCache_.find(currentStateHash); it != legalActionsCache_.end()) {
if (auto resIt = it->second.actionResults.find(key); resIt != it->second.actionResults.end()) {
// We have the next state hash cached!
uint64_t nextStateHash = resIt->second;
// Look up the next state in the cache
if (auto nextIt = legalActionsCache_.find(nextStateHash); nextIt != legalActionsCache_.end()) {
// CACHE HIT: Clone the cached engine and return the state
transitionCacheHits_++;
auto engine = std::make_shared<ShardokEngine>(*nextIt->second.engine);
return std::make_unique<ShardokGameState>(
engine->GetCurrentGameState(),
scoreCalculator_,
gameSettings_.get(),
isDefender_,
strategy_,
castleCoords_,
*apdCache_,
*alCache_,
criticalTileCoords_);
}
}
}
// CACHE MISS: Apply action normally...
transitionCacheMisses_++;
// (existing code from lines 58-98, but change line 82:)
// OLD: engine->PostCommand(currentPlayer, shardokAction->getIndex(), nullptr);
// NEW: engine->PostCommand(currentPlayer, shardokAction->getIndex(), roll);
// After applying, store the transition:
uint64_t nextStateHash = newState->hash();
legalActionsCache_[currentStateHash].actionResults[key] = nextStateHash;
return newState;
}
```
**Similar changes for `applyActionMutable()`** (lines 100-150)
### Phase 3: Testing & Validation (Critical)
1. Add unit tests verifying:
- Cached transitions match actual transitions
- Performance improvement measurable
- No correctness regressions
2. Add debug assertions:
- Verify determinism: cached result == recomputed result (sample check)
- Detect hash collisions (optional, performance cost)
3. Add performance tracking:
- Count cache hits vs misses
- Measure time saved
- Monitor memory usage
### Phase 4: Optimization (Optional)
1. Tune cache eviction policy if memory becomes issue
2. Consider bloom filter to quickly reject cache misses
3. Profile to ensure cache lookups aren't dominating cost
## Performance Expectations
**Conservative estimate:**
- PostCommand might take ~10-50 microseconds (allocations, state updates)
- Hash table lookup takes ~100-500 nanoseconds
- If we get 30% cache hit rate, save ~3-15 microseconds per simulation step
- With 100,000 simulations, save 0.3-1.5 seconds per search
**Best case estimate:**
- In dense search trees, might get 70%+ cache hit rate
- Could save 5-10 seconds per search in complex positions
**Measurement needed:** Profile actual PostCommand cost and cache hit rates.
## Risks
**HIGH RISK:**
- Non-determinism in PostCommand would cause silent correctness bugs
- Must thoroughly test determinism before enabling in production
**MEDIUM RISK:**
- Memory usage could grow large in long-running games
- Need monitoring and eviction policy
**LOW RISK:**
- Implementation complexity moderate but manageable
- Can be feature-flagged and disabled if problems arise
## Recommendation
**This optimization appears sound IF PostCommand is deterministic.**
**Suggested approach:**
1. First, verify PostCommand determinism with extensive testing
2. Implement with feature flag (can disable if issues found)
3. Add comprehensive debug assertions
4. Profile to verify performance improvement justifies complexity
5. Monitor memory usage in production
**Key verification needed before proceeding:**
- Confirm PostCommand has no randomness
- Confirm action indices are stable
- Measure baseline PostCommand performance cost
## Alternative Considered: Lighter-Weight Caching
Instead of storing full state transitions, just cache:
```cpp
unordered_map<pair<uint64_t, size_t>, uint64_t> globalTransitionCache;
// Maps (state_hash, action_index) -> next_state_hash
```
**Pros:**
- Simpler data structure
- Easier to implement cache eviction
**Cons:**
- Requires two hash lookups per transition (this map, then transposition table)
- Doesn't integrate as cleanly with existing transposition table
**Verdict:** Proposed approach (extending LegalActionsCache) is cleaner and integrates with existing infrastructure.
## Files to Modify
### Phase 1: Data Structure
1. **`src/main/cpp/net/eagle0/shardok/ai/mcts/adapters/ShardokGameEngine.hpp`**
- Add `TransitionKey` struct with equality operator
- Add `TransitionKeyHash` struct for hashing
- Modify `LegalActionsCache` to use `gtl::flat_hash_map<TransitionKey, uint64_t, TransitionKeyHash> actionResults;`
- Add thread-local statistics: `transitionCacheHits_`, `transitionCacheMisses_`
### Phase 2: Implementation
2. **`src/main/cpp/net/eagle0/shardok/ai/mcts/adapters/ShardokGameEngine.cpp`**
- Initialize thread-local statistics variables
- Modify `applyAction()`:
- Line 82: Change `nullptr` to `50` for roll parameter
- Add transition cache lookup using `TransitionKey{actionIndex, 50}`
- Add transition cache storage after PostCommand
- Modify `applyActionMutable()`:
- Line 133: Change `nullptr` to `50` for roll parameter
- Add same transition cache logic
- Update `reportCacheStatistics()` to include transition cache hit/miss stats
### Phase 3: Testing
3. **`src/test/cpp/net/eagle0/shardok/ai/mcts/ShardokMCTSAI_test.cpp`** (or create new test file)
- Add unit tests for determinism verification (same search → same result)
- Add unit tests for transition cache correctness
- Add performance benchmarks comparing with/without transition cache
- Test with multiple rolls to verify tuple caching works correctly
## Related Work
This optimization is related to:
- **Zobrist hashing** in chess engines (incremental hash updates)
- **Transposition tables with move ordering** in minimax search
- **Memoization** in dynamic programming
Shardok's approach is most similar to transposition tables in chess engines, but applied to MCTS instead of minimax.
+17 -87
View File
@@ -26,75 +26,56 @@ scala_config = use_extension(
"@rules_scala//scala/extensions:config.bzl",
"scala_config",
)
scala_config.settings(scala_version = SCALA_VERSION)
scala_deps = use_extension(
"@rules_scala//scala/extensions:deps.bzl",
"scala_deps",
)
scala_deps.scala()
scala_deps.scalatest()
scala_deps.scala_proto()
#
# Language Support - C++
#
bazel_dep(name = "toolchains_llvm", version = "1.6.0")
bazel_dep(name = "toolchains_llvm", version = "1.4.0")
llvm = use_extension("@toolchains_llvm//toolchain/extensions:llvm.bzl", "llvm")
# Native toolchain (macOS -> macOS, Linux -> Linux)
llvm.toolchain(
name = "llvm_toolchain",
llvm_version = "20.1.2",
)
# Cross-compilation toolchain (macOS -> Linux x86_64)
# Uses the same LLVM distribution but with a Linux sysroot
llvm.toolchain(
name = "llvm_toolchain_linux",
llvm_version = "20.1.2",
)
# Linux 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",
sha256 = "a06475004fe8003ae7ccb4fe1d5511feb9b27cce4a8826eb1dfd686ed83f3dba",
urls = ["https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/v3/ubuntu_noble_amd64_sysroot.tar.xz"],
)
use_repo(llvm, "llvm_toolchain")
#
# 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_go", repo_name = "io_bazel_rules_go", version = "0.56.1")
bazel_dep(name = "gazelle", repo_name = "bazel_gazelle", version = "0.45.0")
go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk")
go_sdk.download(version = "1.23.3")
go_deps = use_extension("@bazel_gazelle//:extensions.bzl", "go_deps")
go_deps.from_file(go_mod = "//:go.mod")
use_repo(
go_deps,
"com_github_aws_aws_sdk_go_v2",
"com_github_aws_aws_sdk_go_v2_config",
"com_github_aws_aws_sdk_go_v2_credentials",
"com_github_aws_aws_sdk_go_v2_service_s3",
"org_golang_google_grpc",
"org_golang_google_protobuf",
)
@@ -102,15 +83,15 @@ use_repo(
# Platform Support - Apple/iOS
#
bazel_dep(name = "apple_support", version = "1.21.1", repo_name = "build_bazel_apple_support")
bazel_dep(name = "rules_apple", version = "3.16.1", repo_name = "build_bazel_rules_apple")
bazel_dep(name = "rules_swift", version = "2.3.1", repo_name = "build_bazel_rules_swift")
bazel_dep(name = "apple_support", repo_name = "build_bazel_apple_support", version = "1.21.1")
bazel_dep(name = "rules_apple", repo_name = "build_bazel_rules_apple", version = "3.16.1")
bazel_dep(name = "rules_swift", repo_name = "build_bazel_rules_swift", version = "2.3.1")
#
# Protocol Buffers & RPC
#
bazel_dep(name = "protobuf", version = "29.2", repo_name = "com_google_protobuf")
bazel_dep(name = "protobuf", repo_name = "com_google_protobuf", version = "29.2")
bazel_dep(name = "grpc", version = "1.71.0")
bazel_dep(name = "grpc-java", version = "1.71.0")
bazel_dep(name = "flatbuffers", version = "25.2.10")
@@ -121,40 +102,6 @@ bazel_dep(name = "flatbuffers", version = "25.2.10")
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 JDK - includes jcmd for JFR dumps)
oci.pull(
name = "eclipse_temurin_17",
image = "docker.io/library/eclipse-temurin",
platforms = ["linux/amd64"],
tag = "17-jdk",
)
# Base image for Shardok (Ubuntu 24.04 for C++ runtime)
oci.pull(
name = "ubuntu_24_04",
image = "docker.io/library/ubuntu",
platforms = ["linux/amd64"],
tag = "24.04",
)
# Base image for Admin Server (Alpine for lightweight Go binary)
oci.pull(
name = "alpine_linux",
image = "docker.io/library/alpine",
platforms = ["linux/amd64"],
tag = "3.21",
)
use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_17", "eclipse_temurin_17_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64")
#
# Java/Scala Dependencies
#
@@ -162,6 +109,7 @@ use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_17",
bazel_dep(name = "rules_jvm_external", version = "6.3")
maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")
maven.install(
artifacts = [
# Netty
@@ -212,13 +160,6 @@ maven.install(
# Other
"org.reactivestreams:reactive-streams:1.0.4",
"javax.xml.bind:jaxb-api:2.3.1",
# OkHttp (for SSE with read timeout support, OAuth HTTP calls)
"com.squareup.okhttp3:okhttp:4.12.0",
"com.squareup.okhttp3:okhttp-sse:4.12.0",
# JWT (for OAuth token handling)
"com.nimbusds:nimbus-jose-jwt:9.37.3",
],
duplicate_version_warning = "error",
fail_if_repin_required = True,
@@ -227,6 +168,7 @@ maven.install(
"https://repo1.maven.org/maven2",
],
)
use_repo(maven, "maven", "unpinned_maven")
#
@@ -234,7 +176,6 @@ use_repo(maven, "maven", "unpinned_maven")
#
http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file")
# GTL (for parallel_hashmap)
GTL_VERSION = "1.2.0"
@@ -263,16 +204,6 @@ http_archive(
],
)
# Busybox static binary for Docker health checks (provides nc, wget, etc.)
# https://busybox.net/downloads/binaries/
http_file(
name = "busybox_x86_64",
sha256 = "6e123e7f3202a8c1e9b1f94d8941580a25135382b99e8d3e34fb858bba311348",
urls = ["https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox"],
downloaded_file_path = "busybox",
executable = True,
)
#
# Toolchain Registration
#
@@ -285,6 +216,5 @@ register_toolchains(
# Set dev_dependency so we can turn this off for swift MacOS builds
register_toolchains(
"@llvm_toolchain//:all",
"@llvm_toolchain_linux//:all",
dev_dependency = True,
)
+88 -308
View File
@@ -26,11 +26,7 @@
"https://bcr.bazel.build/modules/aspect_bazel_lib/1.38.0/MODULE.bazel": "6307fec451ba9962c1c969eb516ebfe1e46528f7fa92e1c9ac8646bef4cdaa3f",
"https://bcr.bazel.build/modules/aspect_bazel_lib/1.40.3/MODULE.bazel": "668e6bcb4d957fc0e284316dba546b705c8d43c857f87119619ee83c4555b859",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.11.0/MODULE.bazel": "cb1ba9f9999ed0bc08600c221f532c1ddd8d217686b32ba7d45b0713b5131452",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.14.0/MODULE.bazel": "2b31ffcc9bdc8295b2167e07a757dbbc9ac8906e7028e5170a3708cecaac119f",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.16.0/MODULE.bazel": "852f9ebbda017572a7c113a2434592dd3b2f55cd9a0faea3d4be5a09a59e4900",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/MODULE.bazel": "253d739ba126f62a5767d832765b12b59e9f8d2bc88cc1572f4a73e46eb298ca",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/source.json": "ffab9254c65ba945f8369297ad97ca0dec213d3adc6e07877e23a48624a8b456",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.2/MODULE.bazel": "780d1a6522b28f5edb7ea09630748720721dfe27690d65a2d33aa7509de77e07",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.11.0/source.json": "92494d5aa43b96665397dd13ee16023097470fa85e276b93674d62a244de47ee",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.7/MODULE.bazel": "491f8681205e31bb57892d67442ce448cda4f472a8e6b3dc062865e29a64f89c",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.8.1/MODULE.bazel": "812d2dd42f65dca362152101fbec418029cc8fd34cbad1a2fde905383d705838",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.9.3/MODULE.bazel": "66baf724dbae7aff4787bf2245cc188d50cb08e07789769730151c0943587c14",
@@ -55,11 +51,8 @@
"https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58",
"https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b",
"https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65",
"https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d",
"https://bcr.bazel.build/modules/bazel_features/1.27.0/source.json": "ed8cf0ef05c858dce3661689d0a2b110ff398e63994e178e4f1f7555a8067fed",
"https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9",
"https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e",
"https://bcr.bazel.build/modules/bazel_features/1.38.0/MODULE.bazel": "f9b8a9c890ebd216b4049fd12a31d3c2602e3403c7af636b04fbbd7453edc9c9",
"https://bcr.bazel.build/modules/bazel_features/1.38.0/source.json": "31ba776c122b54a2885e23651642e32f087a87bf025465f8040751894b571277",
"https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7",
"https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b",
"https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a",
@@ -76,8 +69,7 @@
"https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d",
"https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.2/source.json": "34a3c8bcf233b835eb74be9d628899bb32999d3e0eadef1947a0a562a2b16ffb",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.1/source.json": "7ebaefba0b03efe59cac88ed5bbc67bcf59a3eff33af937345ede2a38b2d368a",
"https://bcr.bazel.build/modules/bazel_worker_api/0.0.6/MODULE.bazel": "fd1f9432ca04c947e91b500df69ce7c5b6dbfe1bc45ab1820338205dae3383a6",
"https://bcr.bazel.build/modules/bazel_worker_api/0.0.6/source.json": "5d68545f224904745a3cabd35aea6bc2b6cc5a78b7f49f3f69660eab2eeeb273",
"https://bcr.bazel.build/modules/boringssl/0.0.0-20211025-d4f1ab9/MODULE.bazel": "6ee6353f8b1a701fe2178e1d925034294971350b6d3ac37e67e5a7d463267834",
@@ -106,8 +98,6 @@
"https://bcr.bazel.build/modules/envoy_api/0.0.0-20250128-4de3c74/source.json": "028519164a2e24563f4b43d810fdedc702daed90e71e7042d45ba82ad807b46f",
"https://bcr.bazel.build/modules/flatbuffers/25.2.10/MODULE.bazel": "dab15cafe8512d2c4a8daa44c2d7968c5c79f01e220d40076cdc260bf58605e2",
"https://bcr.bazel.build/modules/flatbuffers/25.2.10/source.json": "7eae7ea3eb913b9802426e4d5df11d6c6072a3573a548f8cabf1e965f5cca4d0",
"https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/MODULE.bazel": "cdf8cbe5ee750db04b78878c9633cc76e80dcf4416cbe982ac3a9222f80713c8",
"https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/source.json": "fa7b512dfcb5eafd90ce3959cf42a2a6fe96144ebbb4b3b3928054895f2afac2",
"https://bcr.bazel.build/modules/gazelle/0.27.0/MODULE.bazel": "3446abd608295de6d90b4a8a118ed64a9ce11dcb3dda2dc3290a22056bd20996",
"https://bcr.bazel.build/modules/gazelle/0.30.0/MODULE.bazel": "f888a1effe338491f35f0e0e85003b47bb9d8295ccba73c37e07702d8d31c65b",
"https://bcr.bazel.build/modules/gazelle/0.32.0/MODULE.bazel": "b499f58a5d0d3537f3cf5b76d8ada18242f64ec474d8391247438bf04f58c7b8",
@@ -147,10 +137,6 @@
"https://bcr.bazel.build/modules/grpc/1.70.1/MODULE.bazel": "b800cd8e3e7555c1e61cba2e02d3a2fcf0e91f66e800db286d965d3b7a6a721a",
"https://bcr.bazel.build/modules/grpc/1.71.0/MODULE.bazel": "7fcab2c05530373f1a442c362b17740dd0c75b6a2a975eec8f5bf4c70a37928a",
"https://bcr.bazel.build/modules/grpc/1.71.0/source.json": "60ef8c4c72c8280ae94c05b4f38bf67785acb25477ab8dbac096a9604449ff90",
"https://bcr.bazel.build/modules/helly25_bzl/0.3.1/MODULE.bazel": "3a4be20f6fc13be32ad44643b8252ef5af09eee936f1d943cd4fd7867fa92826",
"https://bcr.bazel.build/modules/helly25_bzl/0.3.1/source.json": "b129ab1828492de2c163785bbeb4065c166de52d932524b4317beb5b7f917994",
"https://bcr.bazel.build/modules/jq.bzl/0.1.0/MODULE.bazel": "2ce69b1af49952cd4121a9c3055faa679e748ce774c7f1fda9657f936cae902f",
"https://bcr.bazel.build/modules/jq.bzl/0.1.0/source.json": "746bf13cac0860f091df5e4911d0c593971cd8796b5ad4e809b2f8e133eee3d5",
"https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075",
"https://bcr.bazel.build/modules/jsoncpp/1.9.5/source.json": "4108ee5085dd2885a341c7fab149429db457b3169b86eb081fa245eadf69169d",
"https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902",
@@ -175,7 +161,6 @@
"https://bcr.bazel.build/modules/opentelemetry-proto/1.5.0/source.json": "046b721ce203e88cdaad44d7dd17a86b7200eab9388b663b234e72e13ff7b143",
"https://bcr.bazel.build/modules/opentracing-cpp/1.6.0/MODULE.bazel": "b3925269f63561b8b880ae7cf62ccf81f6ece55b62cd791eda9925147ae116ec",
"https://bcr.bazel.build/modules/opentracing-cpp/1.6.0/source.json": "da1cb1add160f5e5074b7272e9db6fd8f1b3336c15032cd0a653af9d2f484aed",
"https://bcr.bazel.build/modules/package_metadata/0.0.2/MODULE.bazel": "fb8d25550742674d63d7b250063d4580ca530499f045d70748b1b142081ebb92",
"https://bcr.bazel.build/modules/package_metadata/0.0.5/MODULE.bazel": "ef4f9439e3270fdd6b9fd4dbc3d2f29d13888e44c529a1b243f7a31dfbc2e8e4",
"https://bcr.bazel.build/modules/package_metadata/0.0.5/source.json": "2326db2f6592578177751c3e1f74786b79382cd6008834c9d01ec865b9126a85",
"https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5",
@@ -240,14 +225,12 @@
"https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc",
"https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87",
"https://bcr.bazel.build/modules/rules_cc/0.0.17/MODULE.bazel": "2ae1d8f4238ec67d7185d8861cb0a2cdf4bc608697c331b95bf990e69b62e64a",
"https://bcr.bazel.build/modules/rules_cc/0.0.17/source.json": "4db99b3f55c90ab28d14552aa0632533e3e8e5e9aea0f5c24ac0014282c2a7c5",
"https://bcr.bazel.build/modules/rules_cc/0.0.2/MODULE.bazel": "6915987c90970493ab97393024c156ea8fb9f3bea953b2f3ec05c34f19b5695c",
"https://bcr.bazel.build/modules/rules_cc/0.0.5/MODULE.bazel": "be41f87587998fe8890cd82ea4e848ed8eb799e053c224f78f3ff7fe1a1d9b74",
"https://bcr.bazel.build/modules/rules_cc/0.0.6/MODULE.bazel": "abf360251023dfe3efcef65ab9d56beefa8394d4176dd29529750e1c57eaa33f",
"https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e",
"https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5",
"https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513",
"https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8",
"https://bcr.bazel.build/modules/rules_cc/0.2.14/source.json": "55d0a4587c5592fad350f6e698530f4faf0e7dd15e69d43f8d87e220c78bea54",
"https://bcr.bazel.build/modules/rules_foreign_cc/0.10.1/MODULE.bazel": "b9527010e5fef060af92b6724edb3691970a5b1f76f74b21d39f7d433641be60",
"https://bcr.bazel.build/modules/rules_foreign_cc/0.10.1/source.json": "9300e71df0cdde0952f10afff1401fa664e9fc5d9ae6204660ba1b158d90d6a6",
"https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6",
@@ -306,8 +289,6 @@
"https://bcr.bazel.build/modules/rules_nodejs/6.3.0/MODULE.bazel": "45345e4aba35dd6e4701c1eebf5a4e67af4ed708def9ebcdc6027585b34ee52d",
"https://bcr.bazel.build/modules/rules_nodejs/6.3.3/MODULE.bazel": "b66eadebd10f1f1b25f52f95ab5213a57e82c37c3f656fcd9a57ad04d2264ce7",
"https://bcr.bazel.build/modules/rules_nodejs/6.3.3/source.json": "45bd343155bdfed2543f0e39b80ff3f6840efc31975da4b5795797f4c94147ad",
"https://bcr.bazel.build/modules/rules_oci/2.2.6/MODULE.bazel": "2ba6ddd679269e00aeffe9ca04faa2d0ca4129650982c9246d0d459fe2da47d9",
"https://bcr.bazel.build/modules/rules_oci/2.2.6/source.json": "94e7decb8f95d9465b0bbea71c65064cd16083be1350c7468f131818641dc4a5",
"https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc",
"https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff",
"https://bcr.bazel.build/modules/rules_pkg/1.1.0/MODULE.bazel": "9db8031e71b6ef32d1846106e10dd0ee2deac042bd9a2de22b4761b0c3036453",
@@ -340,8 +321,7 @@
"https://bcr.bazel.build/modules/rules_scala/7.1.1/source.json": "5038cb231d4020c5965c920681cf961a7bf137b40315025e40f3a7b6a0ac1f0f",
"https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c",
"https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b",
"https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592",
"https://bcr.bazel.build/modules/rules_shell/0.4.1/source.json": "4757bd277fe1567763991c4425b483477bb82e35e777a56fd846eb5cceda324a",
"https://bcr.bazel.build/modules/rules_shell/0.3.0/source.json": "c55ed591aa5009401ddf80ded9762ac32c358d2517ee7820be981e2de9756cf3",
"https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca",
"https://bcr.bazel.build/modules/rules_swift/1.18.0/MODULE.bazel": "a6aba73625d0dc64c7b4a1e831549b6e375fbddb9d2dde9d80c9de6ec45b24c9",
"https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046",
@@ -359,19 +339,14 @@
"https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216",
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91",
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/source.json": "32bd87e5f4d7acc57c5b2ff7c325ae3061d5e242c0c4c214ae87e0f1c13e54cb",
"https://bcr.bazel.build/modules/tar.bzl/0.2.1/MODULE.bazel": "52d1c00a80a8cc67acbd01649e83d8dd6a9dc426a6c0b754a04fe8c219c76468",
"https://bcr.bazel.build/modules/tar.bzl/0.6.0/MODULE.bazel": "a3584b4edcfafcabd9b0ef9819808f05b372957bbdff41601429d5fd0aac2e7c",
"https://bcr.bazel.build/modules/tar.bzl/0.6.0/source.json": "4a620381df075a16cb3a7ed57bd1d05f7480222394c64a20fa51bdb636fda658",
"https://bcr.bazel.build/modules/toolchains_llvm/1.6.0/MODULE.bazel": "39603859cafb1c6830160fcd6370552e836790e6abb2bfb8d13bff53c0c10a64",
"https://bcr.bazel.build/modules/toolchains_llvm/1.6.0/source.json": "6bd3ef95a288dd2bb1582eca332af850c9a5428a23bb92cb1c57c2dfe6cb7369",
"https://bcr.bazel.build/modules/toolchains_llvm/1.4.0/MODULE.bazel": "05239402b7374293359c2f22806f420b75aa5d6f4b15a2eaa809a2c214d58b31",
"https://bcr.bazel.build/modules/toolchains_llvm/1.4.0/source.json": "229a516d282b17a82be54c6e3ae220a1b750fb55a8495567e5c7a9d09423f3e2",
"https://bcr.bazel.build/modules/upb/0.0.0-20211020-160625a/MODULE.bazel": "6cced416be2dc5b9c05efd5b997049ba795e5e4e6fafbe1624f4587767638928",
"https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43",
"https://bcr.bazel.build/modules/upb/0.0.0-20230516-61a97ef/MODULE.bazel": "c0df5e35ad55e264160417fd0875932ee3c9dda63d9fccace35ac62f45e1b6f9",
"https://bcr.bazel.build/modules/upb/0.0.0-20230907-e7430e6/MODULE.bazel": "3a7dedadf70346e678dc059dbe44d05cbf3ab17f1ce43a1c7a42edc7cbf93fd9",
"https://bcr.bazel.build/modules/xds/0.0.0-20240423-555b57e/MODULE.bazel": "cea509976a77e34131411684ef05a1d6ad194dd71a8d5816643bc5b0af16dc0f",
"https://bcr.bazel.build/modules/xds/0.0.0-20240423-555b57e/source.json": "7227e1fcad55f3f3cab1a08691ecd753cb29cc6380a47bc650851be9f9ad6d20",
"https://bcr.bazel.build/modules/yq.bzl/0.1.1/MODULE.bazel": "9039681f9bcb8958ee2c87ffc74bdafba9f4369096a2b5634b88abc0eaefa072",
"https://bcr.bazel.build/modules/yq.bzl/0.1.1/source.json": "2d2bad780a9f2b9195a4a370314d2c17ae95eaa745cefc2e12fbc49759b15aa3",
"https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0",
"https://bcr.bazel.build/modules/zlib/1.2.12/MODULE.bazel": "3b1a8834ada2a883674be8cbd36ede1b6ec481477ada359cd2d3ddc562340b27",
"https://bcr.bazel.build/modules/zlib/1.2.13/MODULE.bazel": "aa6deb1b83c18ffecd940c4119aff9567cd0a671d7bba756741cb2ef043a29d5",
@@ -413,7 +388,7 @@
},
"@@aspect_rules_esbuild~//esbuild:extensions.bzl%esbuild": {
"general": {
"bzlTransitiveDigest": "8jv3p0xDR/oitFeH8y0+Y5xlyrUbfsTRlc9TSwYkwl8=",
"bzlTransitiveDigest": "8iOqbPY5ve3DvjzaI1mJZ8XTiJypN2PeWvcKOvmZLy8=",
"usagesDigest": "iDVoyPxUeADmfK8ssoyG3Ehq1bj6p7A43LpEiE266os=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
@@ -1290,280 +1265,6 @@
"recordedRepoMappingEntries": []
}
},
"@@rules_oci~//oci:extensions.bzl%oci": {
"general": {
"bzlTransitiveDigest": "FaY+7xb13bB3hmxqwAWaGp3Tf3Q4Nfdlr+F38CP5mcg=",
"usagesDigest": "uqsqMpE+yaVuTByR2rIA2v1Vkm1JwwuN6nbXOo1W9G0=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"eclipse_temurin_17_linux_amd64": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_pull",
"attributes": {
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/eclipse-temurin",
"identifier": "17-jdk",
"platform": "linux/amd64",
"target_name": "eclipse_temurin_17_linux_amd64",
"bazel_tags": []
}
},
"eclipse_temurin_17": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_alias",
"attributes": {
"target_name": "eclipse_temurin_17",
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/eclipse-temurin",
"identifier": "17-jdk",
"platforms": {
"@@platforms//cpu:x86_64": "@eclipse_temurin_17_linux_amd64"
},
"bzlmod_repository": "eclipse_temurin_17",
"reproducible": true
}
},
"ubuntu_24_04_linux_amd64": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_pull",
"attributes": {
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/ubuntu",
"identifier": "24.04",
"platform": "linux/amd64",
"target_name": "ubuntu_24_04_linux_amd64",
"bazel_tags": []
}
},
"ubuntu_24_04": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_alias",
"attributes": {
"target_name": "ubuntu_24_04",
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/ubuntu",
"identifier": "24.04",
"platforms": {
"@@platforms//cpu:x86_64": "@ubuntu_24_04_linux_amd64"
},
"bzlmod_repository": "ubuntu_24_04",
"reproducible": true
}
},
"alpine_linux_linux_amd64": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_pull",
"attributes": {
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/alpine",
"identifier": "3.21",
"platform": "linux/amd64",
"target_name": "alpine_linux_linux_amd64",
"bazel_tags": []
}
},
"alpine_linux": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_alias",
"attributes": {
"target_name": "alpine_linux",
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/alpine",
"identifier": "3.21",
"platforms": {
"@@platforms//cpu:x86_64": "@alpine_linux_linux_amd64"
},
"bzlmod_repository": "alpine_linux",
"reproducible": true
}
},
"oci_crane_darwin_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "darwin_amd64",
"crane_version": "v0.18.0"
}
},
"oci_crane_darwin_arm64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "darwin_arm64",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_arm64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_arm64",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_armv6": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_armv6",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_i386": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_i386",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_s390x": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_s390x",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_amd64",
"crane_version": "v0.18.0"
}
},
"oci_crane_windows_armv6": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "windows_armv6",
"crane_version": "v0.18.0"
}
},
"oci_crane_windows_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "windows_amd64",
"crane_version": "v0.18.0"
}
},
"oci_crane_toolchains": {
"bzlFile": "@@rules_oci~//oci/private:toolchains_repo.bzl",
"ruleClassName": "toolchains_repo",
"attributes": {
"toolchain_type": "@rules_oci//oci:crane_toolchain_type",
"toolchain": "@oci_crane_{platform}//:crane_toolchain"
}
},
"oci_regctl_darwin_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "darwin_amd64"
}
},
"oci_regctl_darwin_arm64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "darwin_arm64"
}
},
"oci_regctl_linux_arm64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "linux_arm64"
}
},
"oci_regctl_linux_s390x": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "linux_s390x"
}
},
"oci_regctl_linux_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "linux_amd64"
}
},
"oci_regctl_windows_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "windows_amd64"
}
},
"oci_regctl_toolchains": {
"bzlFile": "@@rules_oci~//oci/private:toolchains_repo.bzl",
"ruleClassName": "toolchains_repo",
"attributes": {
"toolchain_type": "@rules_oci//oci:regctl_toolchain_type",
"toolchain": "@oci_regctl_{platform}//:regctl_toolchain"
}
}
},
"moduleExtensionMetadata": {
"explicitRootModuleDirectDeps": [
"eclipse_temurin_17",
"eclipse_temurin_17_linux_amd64",
"ubuntu_24_04",
"ubuntu_24_04_linux_amd64",
"alpine_linux",
"alpine_linux_linux_amd64"
],
"explicitRootModuleDirectDevDeps": [],
"useAllRepos": "NO",
"reproducible": false
},
"recordedRepoMappingEntries": [
[
"aspect_bazel_lib~",
"bazel_tools",
"bazel_tools"
],
[
"bazel_features~",
"bazel_tools",
"bazel_tools"
],
[
"rules_oci~",
"aspect_bazel_lib",
"aspect_bazel_lib~"
],
[
"rules_oci~",
"bazel_features",
"bazel_features~"
],
[
"rules_oci~",
"bazel_skylib",
"bazel_skylib~"
]
]
}
},
"@@rules_scala~//scala/extensions:config.bzl%scala_config": {
"general": {
"bzlTransitiveDigest": "TdBxhkZTM7VU6teIFS+KoonKU7wmb5BL7leCWWx7yX8=",
@@ -1592,7 +1293,7 @@
},
"@@rules_scala~//scala/extensions:deps.bzl%scala_deps": {
"general": {
"bzlTransitiveDigest": "5SDZrXQHW6tI/VEw+La2OPOK4ZWm0LGTxnChXOXBCag=",
"bzlTransitiveDigest": "F2PMm61fmZ/IE+VSw1rigJ71hBDD7k3vqyYR1/GgXeA=",
"usagesDigest": "kwo8oolISmSSITnit4b4S0vBiUtHlHK0WLDUwScxmOg=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
@@ -5203,6 +4904,85 @@
]
]
}
},
"@@toolchains_llvm~//toolchain/extensions:llvm.bzl%llvm": {
"general": {
"bzlTransitiveDigest": "afRF0aFOIUrkYl3o040WQ606ep1qciEXzjnAxT3Kek8=",
"usagesDigest": "sYVuhiCAQehFTnGTv0bNtTBR4WorebpWBNxF0mRusyw=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"llvm_toolchain_llvm": {
"bzlFile": "@@toolchains_llvm~//toolchain:rules.bzl",
"ruleClassName": "llvm",
"attributes": {
"alternative_llvm_sources": [],
"auth_patterns": {},
"distribution": "auto",
"exec_arch": "",
"exec_os": "",
"libclang_rt": {},
"llvm_mirror": "",
"llvm_version": "20.1.2",
"llvm_versions": {},
"netrc": "",
"sha256": {},
"strip_prefix": {},
"urls": {}
}
},
"llvm_toolchain": {
"bzlFile": "@@toolchains_llvm~//toolchain:rules.bzl",
"ruleClassName": "toolchain",
"attributes": {
"absolute_paths": false,
"archive_flags": {},
"compile_flags": {},
"conly_flags": {},
"coverage_compile_flags": {},
"coverage_link_flags": {},
"cxx_builtin_include_directories": {},
"cxx_flags": {},
"cxx_standard": {},
"dbg_compile_flags": {},
"exec_arch": "",
"exec_os": "",
"extra_exec_compatible_with": {},
"extra_target_compatible_with": {},
"link_flags": {},
"link_libs": {},
"llvm_versions": {
"": "20.1.2"
},
"opt_compile_flags": {},
"opt_link_flags": {},
"stdlib": {},
"target_settings": {},
"unfiltered_compile_flags": {},
"toolchain_roots": {},
"sysroot": {}
}
}
},
"recordedRepoMappingEntries": [
[
"toolchains_llvm~",
"bazel_skylib",
"bazel_skylib~"
],
[
"toolchains_llvm~",
"bazel_tools",
"bazel_tools"
],
[
"toolchains_llvm~",
"toolchains_llvm",
"toolchains_llvm~"
]
]
}
}
}
}
-239
View File
@@ -1,239 +0,0 @@
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load", "oci_push")
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
#
# Shared utilities layer (busybox for nc, wget, etc.)
#
pkg_tar(
name = "busybox_layer",
srcs = ["@busybox_x86_64//file"],
package_dir = "/usr/local/bin",
remap_paths = {
"file/busybox": "busybox",
},
symlinks = {
"/usr/local/bin/nc": "busybox",
},
)
#
# Eagle Server Docker Image
#
# Build: bazel build //ci:eagle_server_image
# Load: bazel run //ci:eagle_server_load
# Push: bazel run //ci:eagle_server_push
#
# Package the deploy JAR
pkg_tar(
name = "eagle_server_jar_layer",
srcs = ["//src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar"],
package_dir = "/app",
)
# Package the game resources needed at runtime
pkg_tar(
name = "eagle_resources_layer",
srcs = [
"//src/main/resources/net/eagle0/eagle:beasts",
"//src/main/resources/net/eagle0/eagle:game_parameters",
"//src/main/resources/net/eagle0/eagle:headshots",
"//src/main/resources/net/eagle0/eagle:heroes",
"//src/main/resources/net/eagle0/eagle:province_map",
"//src/main/resources/net/eagle0/eagle:settings",
],
package_dir = "/app/resources",
)
oci_image(
name = "eagle_server_image",
base = "@eclipse_temurin_17_linux_amd64",
entrypoint = [
"java",
"-Xmx2g",
"-XX:+UseG1GC",
# JFR profiling support
"-XX:+UnlockDiagnosticVMOptions",
"-XX:+DebugNonSafepoints", # Required for JFR to see through inlined methods
"-XX:FlightRecorderOptions=stackdepth=256",
"-jar",
"/app/eagle_server_deploy.jar",
],
env = {
"JAVA_OPTS": "-Xmx2g -XX:+UseG1GC",
},
exposed_ports = ["40032/tcp"],
tars = [
":busybox_layer",
":eagle_server_jar_layer",
":eagle_resources_layer",
],
workdir = "/app",
)
# Load into Docker locally: bazel run //ci:eagle_server_load
oci_load(
name = "eagle_server_load",
image = ":eagle_server_image",
repo_tags = ["eagle0/eagle-server:latest"],
)
# Push to DigitalOcean Container Registry
# Note: No remote_tags here - DigitalOcean converts OCI to Docker format,
# changing the digest and breaking oci_push's tag-by-digest logic.
# Tagging is handled in the CI workflow using crane copy/tag.
oci_push(
name = "eagle_server_push",
image = ":eagle_server_image",
repository = "registry.digitalocean.com/eagle0/eagle-server",
)
#
# Shardok Server Docker Image
#
# Build: bazel build //ci:shardok_server_image
# Load: bazel run //ci:shardok_server_load
# Push: bazel run //ci:shardok_server_push
#
# Package the Shardok binary
pkg_tar(
name = "shardok_binary_layer",
srcs = ["//src/main/cpp/net/eagle0/shardok:shardok-server"],
package_dir = "/app",
)
# Package the Shardok resources (battalion types, settings)
pkg_tar(
name = "shardok_resources_layer",
srcs = [
"//src/main/resources/net/eagle0/shardok:battalion_types",
"//src/main/resources/net/eagle0/shardok:settings",
],
package_dir = "/app/resources",
)
# Package the converted maps
pkg_tar(
name = "shardok_maps_layer",
srcs = ["//src/main/resources/net/eagle0/shardok/maps"],
package_dir = "/app/resources/maps",
)
oci_image(
name = "shardok_server_image",
base = "@ubuntu_24_04_linux_amd64",
entrypoint = ["/app/shardok-server"],
exposed_ports = [
"40042/tcp",
"40052/tcp",
],
tars = [
":busybox_layer",
":shardok_binary_layer",
":shardok_resources_layer",
":shardok_maps_layer",
],
workdir = "/app",
)
# Load into Docker locally: bazel run //ci:shardok_server_load
oci_load(
name = "shardok_server_load",
image = ":shardok_server_image",
repo_tags = ["eagle0/shardok-server:latest"],
)
# Push to DigitalOcean Container Registry
# Note: No remote_tags here - DigitalOcean converts OCI to Docker format,
# changing the digest and breaking oci_push's tag-by-digest logic.
# Tagging is handled in the CI workflow using crane copy/tag.
oci_push(
name = "shardok_server_push",
image = ":shardok_server_image",
repository = "registry.digitalocean.com/eagle0/shardok-server",
)
#
# Admin Server Docker Image (Go)
#
# Build: bazel build //ci:admin_server_image
# Load: bazel run //ci:admin_server_load
# Push: bazel run //ci:admin_server_push
#
# Package the Go admin binary (explicit Linux x86_64 target)
pkg_tar(
name = "admin_binary_layer",
srcs = ["//src/main/go/net/eagle0/admin_server:admin_server_linux_amd64"],
package_dir = "/app",
)
oci_image(
name = "admin_server_image",
base = "@alpine_linux_linux_amd64",
entrypoint = ["/app/admin_server_linux_amd64"],
exposed_ports = ["8080/tcp"],
tars = [
":busybox_layer",
":admin_binary_layer",
],
workdir = "/app",
)
# Load into Docker locally: bazel run //ci:admin_server_load
oci_load(
name = "admin_server_load",
image = ":admin_server_image",
repo_tags = ["eagle0/admin-server:latest"],
)
# Push to DigitalOcean Container Registry
oci_push(
name = "admin_server_push",
image = ":admin_server_image",
repository = "registry.digitalocean.com/eagle0/admin-server",
)
#
# JFR Sidecar Docker Image (Go + JDK for jcmd)
#
# This sidecar runs with shared PID namespace to access the Eagle JVM.
# Build: bazel build //ci:jfr_sidecar_image
# Load: bazel run //ci:jfr_sidecar_load
# Push: bazel run //ci:jfr_sidecar_push
#
# Package the Go JFR server binary
pkg_tar(
name = "jfr_sidecar_binary_layer",
srcs = ["//src/main/go/net/eagle0/jfr_server:jfr_server_linux_amd64"],
package_dir = "/app",
)
oci_image(
name = "jfr_sidecar_image",
# Use JDK base image - we need jcmd to dump JFR recordings
base = "@eclipse_temurin_17_linux_amd64",
entrypoint = ["/app/jfr_server_linux_amd64"],
exposed_ports = ["8081/tcp"],
tars = [
":jfr_sidecar_binary_layer",
],
workdir = "/app",
)
# Load into Docker locally: bazel run //ci:jfr_sidecar_load
oci_load(
name = "jfr_sidecar_load",
image = ":jfr_sidecar_image",
repo_tags = ["eagle0/jfr-sidecar:latest"],
)
# Push to DigitalOcean Container Registry
oci_push(
name = "jfr_sidecar_push",
image = ":jfr_sidecar_image",
repository = "registry.digitalocean.com/eagle0/jfr-sidecar",
)
+2 -1
View File
@@ -1 +1,2 @@
UNITY_VERSION='6000.3.0f1'
UNITY_VERSION='6000.2.7f2'
-1
View File
@@ -1 +0,0 @@
node_modules/
-46
View File
@@ -1,46 +0,0 @@
# OAuth Relay Worker
Cloudflare Worker that relays OAuth callbacks to the eagle0:// custom URL scheme.
## Why?
Discord (and some other OAuth providers) don't support custom URL schemes as redirect URIs. This worker acts as a relay:
1. Discord redirects to `https://eagle0-oauth-relay.<account>.workers.dev/oauth/callback?code=xxx&state=yyy`
2. Worker responds with 302 redirect to `eagle0://auth/callback?code=xxx&state=yyy`
3. OS opens the Eagle0 app via deep link
## Deploy
1. Install wrangler: `npm install -g wrangler`
2. Login: `wrangler login`
3. Deploy: `wrangler deploy`
The worker will be available at `https://eagle0-oauth-relay.<your-account>.workers.dev`
## Test Locally
```bash
wrangler dev
# Then in another terminal:
curl -I "http://localhost:8787/oauth/callback?code=test&state=abc"
```
## Test Production
```bash
curl -I "https://eagle0-oauth-relay.<your-account>.workers.dev/oauth/callback?code=test&state=abc"
```
Should return:
```
HTTP/2 302
location: eagle0://auth/callback?code=test&state=abc
```
## OAuth Provider Configuration
In Discord Developer Portal / Google Cloud Console, set the redirect URI to:
```
https://eagle0-oauth-relay.<your-account>.workers.dev/oauth/callback
```
-38
View File
@@ -1,38 +0,0 @@
/**
* OAuth Relay Worker
*
* Receives OAuth callbacks from providers (Discord, Google) and redirects
* to the eagle0:// custom URL scheme for the native app to handle.
*
* Input: GET /oauth/callback?code=xxx&state=yyy
* Output: 302 Redirect to eagle0://auth/callback?code=xxx&state=yyy
*/
export default {
async fetch(request) {
const url = new URL(request.url);
// Only handle /oauth/callback path
if (url.pathname !== '/oauth/callback') {
return new Response('Not Found', { status: 404 });
}
// Build the deep link URL
const deepLink = new URL('eagle0://auth/callback');
// Forward all query parameters
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const error = url.searchParams.get('error');
const errorDescription = url.searchParams.get('error_description');
if (code) deepLink.searchParams.set('code', code);
if (state) deepLink.searchParams.set('state', state);
if (error) deepLink.searchParams.set('error', error);
if (errorDescription) deepLink.searchParams.set('error_description', errorDescription);
console.log(`OAuth relay: redirecting to ${deepLink.toString()}`);
return Response.redirect(deepLink.toString(), 302);
}
}
-4
View File
@@ -1,4 +0,0 @@
name = "eagle0-oauth-relay"
main = "worker.js"
compatibility_date = "2024-01-01"
workers_dev = true
-154
View File
@@ -1,154 +0,0 @@
# Docker Compose for production deployment
#
# Local testing:
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:shardok_server_load
# Run: docker compose -f docker-compose.prod.yml up
#
# Production deployment:
# Run: docker compose -f docker-compose.prod.yml up -d
services:
eagle:
image: ${EAGLE_IMAGE:-registry.digitalocean.com/eagle0/eagle-server:latest}
container_name: eagle-server
command:
- "--gpt-model-name"
- "${GPT_MODEL_NAME:-gpt-5.1}"
- "--shardok-interface-remote-address"
- "shardok:40042"
ports:
- "40032:40032"
environment:
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
volumes:
- ./saves:/app/saves
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-server jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
- jvm-tmp:/tmp # Shared with jfr-sidecar for JVM attach socket files
depends_on:
- shardok
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 40032 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
shardok:
image: ${SHARDOK_IMAGE:-registry.digitalocean.com/eagle0/shardok-server:latest}
container_name: shardok-server
mem_limit: 1g
memswap_limit: 1g # Prevent swap, OOM-kill cleanly instead
ports:
- "40042:40042"
- "40052:40052"
environment:
SHARDOK_RESOURCES_PATH: "/app/resources"
SHARDOK_MAPS_PATH: "/app/resources/maps"
SHARDOK_EAGLE_INTERFACE_ADDRESS: "0.0.0.0:40042"
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 40042 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
nginx:
image: nginx:alpine
container_name: nginx
ports:
- "443:443"
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./certbot/conf:/etc/letsencrypt:ro
- ./certbot/www:/var/www/certbot:ro
- ./auth:/etc/nginx/auth:ro
depends_on:
- eagle
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
admin:
image: ${ADMIN_IMAGE:-registry.digitalocean.com/eagle0/admin-server:latest}
container_name: admin-server
command:
- "--eagle-addr"
- "eagle:40032"
- "--jfr-sidecar-addr"
- "jfr-sidecar:8081"
- "--http-port"
- "8080"
ports:
- "8080:8080"
depends_on:
- eagle
- jfr-sidecar
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:8080/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
jfr-sidecar:
image: ${JFR_SIDECAR_IMAGE:-registry.digitalocean.com/eagle0/jfr-sidecar:latest}
container_name: jfr-sidecar
# Share PID namespace with Eagle to access its JVM via jcmd
pid: "service:eagle"
volumes:
- jvm-tmp:/tmp # Shared with Eagle for JVM attach socket files
depends_on:
- eagle
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "2"
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:8081/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
certbot:
image: certbot/certbot
container_name: certbot
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
volumes:
jvm-tmp:
# Shared /tmp for JVM attach socket files between Eagle and jfr-sidecar
-471
View File
@@ -1,471 +0,0 @@
# Admin Server Enhancement Plan
## Overview
This document outlines enhancements to the Go admin server (`src/main/go/net/eagle0/admin_server/`) to provide a proper web UI for game administration.
### Current State
The admin server provides a full web UI with htmx interactivity:
- `GET /` - Redirect to games list
- `GET /games` - Game list page (HTML)
- `GET /games/{id}` - Game detail with action history
- `GET /games/{id}/history` - History rows (htmx partial, infinite scroll)
- `GET /games/{id}/action/{index}` - Action detail (htmx partial)
- `POST /games/{id}/rewind` - Rewind game to target action
- `GET /settings` - Settings list with live search
- `POST /settings/update` - Update setting value
- `GET /health` - Health check (JSON)
- `GET /api/games` - JSON API for programmatic access
- `GET /api/games/{id}/history` - JSON API for history
### Goals
1. **Web UI**: Replace raw JSON with an interactive HTML interface
2. **Settings Management**: View and modify the 275+ game settings at runtime
3. **Game Rewind**: Restore a game to a previous action count
---
## Architecture
### Technology Choice: Go Templates + htmx
**Rationale:**
- Single binary deployment (no separate frontend build)
- htmx provides interactivity without JavaScript framework complexity
- Familiar HTML/CSS, minimal learning curve
- Excellent for admin tools where SEO and bundle size don't matter
**Alternatives Considered:**
- React/Vue SPA: Adds build complexity, separate deployment artifact
- Server-side only: Less interactive, full page reloads
### Directory Structure
```
src/main/go/net/eagle0/admin_server/
├── admin_server.go # Main entry point, HTTP routes
├── handlers/
│ ├── games.go # Game list and detail handlers
│ ├── settings.go # Settings list and update handlers
│ └── rewind.go # Game rewind handlers
├── templates/
│ ├── layout.html # Base layout with nav, htmx includes
│ ├── games/
│ │ ├── list.html # Game list page
│ │ ├── detail.html # Single game view with history
│ │ └── history.html # Partial for history table (htmx)
│ ├── settings/
│ │ ├── list.html # Settings list with search/filter
│ │ └── edit.html # Inline edit partial (htmx)
│ └── rewind/
│ └── confirm.html # Rewind confirmation modal
├── static/
│ ├── style.css # Minimal CSS (Pico CSS or similar)
│ └── htmx.min.js # htmx library
└── BUILD.bazel
```
---
## Feature 1: Web UI
### Routes
| Route | Method | Description |
|-------|--------|-------------|
| `/` | GET | Redirect to `/games` |
| `/games` | GET | Game list page (HTML) |
| `/games/{id}` | GET | Game detail page with history |
| `/games/{id}/history` | GET | History partial (htmx, for infinite scroll) |
| `/api/games` | GET | JSON API (existing, keep for programmatic access) |
| `/api/games/{id}/history` | GET | JSON API (existing) |
### Game List Page
```
┌─────────────────────────────────────────────────────────────┐
│ Eagle Admin [Settings] [Health] │
├─────────────────────────────────────────────────────────────┤
│ │
│ Running Games (3) │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Game abc123f Round 45 │ │
│ │ Players: Liu Bei (Human), Cao Cao (AI), Sun Quan │ │
│ │ Actions: 1,234 [View] [Rewind]│ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Game def456a Round 12 │ │
│ │ Players: Test Player (Human) │ │
│ │ Actions: 456 [View] [Rewind]│ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Game Detail Page
Shows game info and scrollable action history:
- **Reverse chronological order**: Most recent actions displayed first
- Each action shows: index, type, round ID
- **Clickable actions**: Clicking an action row expands to show JSON representation of the full action data
- "Rewind to here" button on each action row
- Infinite scroll loads more history via htmx (loading older actions as user scrolls down)
### Implementation Notes
1. **Embed static files**: Use `//go:embed` to bundle templates and static files
2. **Template functions**: Add helpers for formatting (hex IDs, timestamps, action summaries)
3. **CSS framework**: Use Pico CSS (~10KB) for clean defaults without classes
---
## Feature 2: Settings Management
### New gRPC Endpoints (Eagle Server)
Add to `eagle.proto`:
```protobuf
message Setting {
string name = 1;
string type = 2; // "Int" or "Double"
string value = 3; // Current value as string
string default_value = 4; // Default from BUILD.bazel
string description = 5; // Optional, for UI hints
}
message GetSettingsRequest {
string filter = 1; // Optional name filter (substring match)
}
message GetSettingsResponse {
repeated Setting settings = 1;
}
message UpdateSettingRequest {
string name = 1;
string value = 2;
}
message UpdateSettingResponse {
Setting setting = 1; // Updated setting
string error = 2; // Empty on success
}
service Eagle {
// ... existing methods ...
rpc GetSettings(GetSettingsRequest) returns (GetSettingsResponse);
rpc UpdateSetting(UpdateSettingRequest) returns (UpdateSettingResponse);
}
```
### Eagle Server Implementation
Create a settings registry that:
1. Discovers all `IntSetting` and `DoubleSetting` instances via reflection or explicit registration
2. Provides get/set by name
3. Validates types on update
```scala
// src/main/scala/net/eagle0/eagle/library/settings/SettingsRegistry.scala
object SettingsRegistry {
private val settings: Map[String, Either[IntSetting, DoubleSetting]] = Map(
"ActionVigorCost" -> Left(ActionVigorCost),
"BaseFoodBuyPrice" -> Right(BaseFoodBuyPrice),
// ... register all 275 settings
)
def getAll(filter: Option[String]): Seq[Setting] = ...
def get(name: String): Option[Setting] = ...
def update(name: String, value: String): Either[String, Setting] = ...
}
```
**Alternative: Code generation**
Rather than manually registering 275 settings, modify `setting_rule.bzl` to generate a registry file during build.
### Admin Server Routes
| Route | Method | Description |
|-------|--------|-------------|
| `/settings` | GET | Settings list page with search |
| `/settings/{name}` | GET | Single setting detail (htmx partial) |
| `/settings/{name}` | PUT | Update setting value |
| `/api/settings` | GET | JSON API |
| `/api/settings/{name}` | PUT | JSON API |
### Settings UI
```
┌─────────────────────────────────────────────────────────────┐
│ Eagle Admin [Games] [Health] │
├─────────────────────────────────────────────────────────────┤
│ │
│ Settings [Search: __________ ] │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ActionVigorCost (Int) │ │
│ │ Current: [15 ] Default: 15 [Save] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ BaseFoodBuyPrice (Double) │ │
│ │ Current: [0.5 ] Default: 0.5 [Save] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ... (275 settings, virtualized/paginated) ... │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Considerations
1. **Persistence**: Settings changes are in-memory only. Document that restarts reset to defaults.
2. **Validation**: Validate numeric ranges where applicable (e.g., percentages 0-100)
3. **Categories**: Consider grouping settings by prefix (AI*, Combat*, Economy*, etc.)
4. **Audit log**: Log setting changes with timestamp for debugging
---
## Feature 3: Game Rewind
### Concept
Restore a game to a previous point in its action history. This is useful for:
- Debugging issues that occurred at a specific point
- Testing "what if" scenarios
- Recovering from bugs that corrupted state
### New gRPC Endpoint
Add to `eagle.proto`:
```protobuf
message RewindGameRequest {
int64 game_id = 1;
int32 target_action_count = 2; // Rewind to state after this many actions
}
message RewindGameResponse {
bool success = 1;
string error = 2;
int32 new_action_count = 3;
int32 disconnected_clients = 4; // Number of clients that were disconnected
}
service Eagle {
// ... existing methods ...
rpc RewindGame(RewindGameRequest) returns (RewindGameResponse);
}
```
### Eagle Server Implementation
The `GameHistory` already stores `ActionWithResultingState` for each action, which includes the `GameState` after that action. Rewinding means:
1. **Validate**: Check that `target_action_count` is within valid range (0 to current count)
2. **Get target state**: Retrieve `GameState` at target action count from history
3. **Disconnect clients**: Close all human player connections (they'll need to reconnect)
4. **Replace engine**: Create new `EngineImpl` with target state and truncated history
5. **Reset AI state**: Clear any cached AI state that depends on current game state
```scala
// GameController.scala (pseudocode)
def rewindTo(targetActionCount: Int): Either[String, RewindResult] = {
if (targetActionCount < 0 || targetActionCount > engine.history.count)
return Left(s"Invalid action count: $targetActionCount")
// Get state at target point
val targetState = engine.history.stateAt(targetActionCount)
val truncatedHistory = engine.history.truncateTo(targetActionCount)
// Disconnect all human clients
val disconnectedCount = humanClients.length
humanClients.foreach(_.disconnect("Game rewound by admin"))
// Create new engine at target state
val newEngine = EngineImpl(
gameId = engine.gameId,
currentState = targetState,
history = truncatedHistory,
// ... other fields
)
// Replace controller's engine
this.engine = newEngine
Right(RewindResult(targetActionCount, disconnectedCount))
}
```
### GameHistory Enhancement
Add method to get state at a specific action count:
```scala
trait GameHistory {
// ... existing methods ...
def stateAt(actionCount: Int): GameState = {
if (actionCount == 0) initialState
else all(actionCount - 1).resultingState
}
def truncateTo(actionCount: Int): GameHistory = {
GameHistoryImpl(
initialState = initialState,
actions = all.take(actionCount)
)
}
}
```
### Admin Server Route
| Route | Method | Description |
|-------|--------|-------------|
| `/games/{id}/rewind` | POST | Rewind game (form: `target_action_count`) |
| `/games/{id}/rewind/confirm` | GET | Confirmation modal (htmx partial) |
### Rewind UI Flow
1. User views game history
2. User clicks "Rewind to here" on an action row
3. Confirmation modal appears via htmx:
```
┌─────────────────────────────────────────┐
│ Rewind Game abc123f? │
│ │
│ This will: │
│ • Restore to action 456 (Round 23) │
│ • Discard 778 subsequent actions │
│ • Disconnect 2 connected players │
│ │
│ This cannot be undone. │
│ │
│ [Cancel] [Rewind] │
└─────────────────────────────────────────┘
```
4. On confirm, POST to `/games/{id}/rewind`
5. Success: redirect to game detail showing new state
6. Error: show error message
### Safety Considerations
1. **No undo**: Rewinding discards history. Consider optional backup before rewind.
2. **Client disconnect**: All connected clients are forcibly disconnected.
3. **AI state**: Ensure AI clients restart cleanly after rewind.
4. **Concurrent access**: Lock game during rewind to prevent race conditions.
5. **Authorization**: In production, require admin authentication.
---
## Implementation Phases
### Phase 1: Web UI Foundation
**Status: Complete**
1. ✅ Set up Go templates with `embed`
2. ✅ Add Pico CSS and htmx
3. ✅ Create base layout with navigation
4. ✅ Convert `/games` to HTML with styling
5. ✅ Add game detail page with history table
6. ✅ Implement htmx infinite scroll for history
7. ✅ Reverse history order (most recent first)
8. ✅ Clickable action rows that expand to show JSON representation
9. ✅ Add `/games/{id}/action/{index}` endpoint for fetching action details
**Deliverable**: Browsable game list and history in HTML with clickable action details
### Phase 2: Settings Management
**Status: Complete**
1. ✅ Add `GetSettings` to `eagle.proto` (uses existing `AddSettings` for updates)
2. ✅ Add `getAllSettings` method to auto-generated `SettingsLoader`
3. ✅ Implement `getSettings` in `EagleServiceImpl`
4. ✅ Create settings list page with live search
5. ✅ Add inline editing with htmx
6. ✅ Modified settings are highlighted
**Deliverable**: View and edit settings via admin UI
### Phase 3: Game Rewind
**Status: Complete**
1. ✅ Add `RewindGame` to `eagle.proto`
2. ✅ Implement `stateAt` and `truncateTo` in `GameHistory`
3. ✅ Implement rewind logic in `Engine` and `GameController`
4. ✅ Add rewind confirmation (htmx `hx-confirm` dialog)
5. ✅ Handle client disconnection gracefully
6. ✅ Add rewind button to history rows
7. ✅ Implement `rewindGame` in `GamesManager` and `EagleServiceImpl`
8. ✅ Add admin server `/games/{id}/rewind` POST handler
9. ✅ Add success/error feedback UI
**Deliverable**: Rewind games to any previous action
### Phase 4: Polish
**Status: Not Started**
#### High Priority
1. **Add tests for rewind functionality**
- `PersistedHistory.truncateTo` (handles complex persisted vs recent logic)
- `InMemoryHistory.truncateTo`
- `EngineImpl.rewindTo`
- `GameController.rewindTo`
- `GamesManager.rewindGame`
2. **Improve action history display**
- Human-readable action type names (e.g., "New Round" instead of "NewRoundAction")
- Show acting faction/province when available
- Action summaries from the `summary` field in `GameHistoryEntry`
#### Medium Priority
3. **Settings improvements**
- Group settings by category prefix (AI*, Combat*, Economy*, etc.)
- Show setting descriptions where available
- Pagination for large settings lists
4. **Error handling improvements**
- Better error messages on failed operations
- Retry logic for transient gRPC failures
#### Low Priority (Nice to Have)
5. **Basic auth** - HTTP Basic Auth or OAuth for production use
6. **Audit logging** - Log admin actions with timestamps
7. **Documentation** - Usage guide, deployment notes
#### Future Considerations
- Game creation from admin UI
- Player management (view connected players, force disconnect)
- Export game history to file
- Metrics/stats dashboard
---
## Security Notes
The admin server is intended for local/trusted network use only. For production:
1. **Do not expose to public internet** without authentication
2. Consider adding HTTP Basic Auth or OAuth
3. Run on internal network or behind VPN
4. Log all admin actions for audit trail
---
## Open Questions
1. **Settings persistence**: Should we add optional persistence to disk/database?
2. **Game snapshots**: Should rewind create a backup first?
3. **Multi-admin**: Need locking if multiple admins access simultaneously?
4. **Shardok settings**: Are there Shardok (C++) settings to expose too?
-280
View File
@@ -1,280 +0,0 @@
# CommandProto Usage Analysis in shardok/ai
This document analyzes all remaining usages of `CommandProto` (protocol buffer representation) in the AI code and identifies opportunities to eliminate proto conversion by using `ShardokCommand` directly.
## Summary
**Total CommandProto usages found:** 42 locations across 9 files
**Eliminated:** 6 usages (14%) - ✅ **Phase 1 Complete**
**Can be eliminated:** ~14 usages (33%)
**Must keep (for now):** ~22 usages (53%)
---
## Files with CommandProto Usage
### 1. AICommandFilter.cpp (6 usages) - ✅ **COMPLETED** (PR #4505)
**Location:** Lines 146, 189, 252, 356, 387, 428
**Original usage:**
```cpp
const auto cmdProto = cmd.GetCommandProto();
if (!cmdProto.has_target()) { ... }
const auto& targetCoords = cmdProto.target();
if (!cmdProto.has_actor()) { ... }
const auto unitId = cmdProto.actor().value();
```
**Replaced with:**
```cpp
const int targetRow = cmd.GetTargetRow();
const int targetCol = cmd.GetTargetColumn();
if (targetRow < 0 || targetCol < 0) {
throw ShardokInternalErrorException("Command missing required target");
}
const Coords targetCoords(targetRow, targetCol);
const int actorId = cmd.GetActorUnitId();
if (actorId < 0) {
throw ShardokInternalErrorException("Command missing required actor");
}
```
**Status:****ELIMINATED** - Replaced with direct accessors + exception handling
**Impact:** Eliminated 6 proto conversions in hot path (command filtering)
**Completed:** Phase 1, PR #4505
---
### 2. ShardokAIClient.cpp (8 usages)
**Location:** Lines 83, 86, 87, 102, 105, 237, 261, 311, 356
**Usage breakdown:**
#### a) Command validation (lines 83-87)
```cpp
void CheckCommand(const CommandProto &realDescriptor, const CommandProto &guessedDescriptor) {
differencer.IgnoreField(CommandProto::descriptor()->FindFieldByNumber(
CommandProto::kFollowUpCommandTypesFieldNumber));
```
**Status:****MUST KEEP** - Uses protobuf reflection for comparison
**Reason:** Comparing proto messages for correctness checking requires proto API
#### b) GetAvailableCommandProtos calls (lines 105, 356)
```cpp
const auto guessedCommands = guessedEngine.GetAvailableCommandProtos(playerId, false);
if (const auto &availableCommands = engine.GetAvailableCommandProtos(playerId, false);
```
**Status:****CAN REPLACE** - Should use `GetAvailableCommandsForAIPlayer()` instead
**Impact:** This is a major conversion point - converts entire command list to protos
**Priority:** HIGH (converts all commands to proto unnecessarily)
#### c) Strategy selector methods (lines 102, 237, 261, 311)
```cpp
const vector<CommandProto> &realAvailableCommands) const -> CommandChoiceResults
```
**Status:****CAN REPLACE** - Depends on fixing strategy selector signatures
**Priority:** MEDIUM (depends on other refactors)
---
### 3. IterativeDeepeningAI.cpp/hpp (4 usages)
**Location:** Lines 41, 272 (cpp), 73, 96 (hpp)
**Current usage:**
```cpp
const std::vector<CommandProto>& commands,
```
**Status:****CAN REPLACE** - These methods should accept `CommandListSPtr` instead
**Impact:** Major - this is the main AI search algorithm
**Priority:** HIGH (core AI algorithm)
**Note:** IterativeDeepeningAI already receives commands as proto vectors. The conversion happens upstream at the entry point. Need to trace back to find where `GetAvailableCommandProtos` is called.
---
### 4. AIFleeDecisionCalculator.cpp/hpp (6 usages)
**Location:** Lines 17, 38, 39, 62, 63 (hpp), 18, 19, 137, 138 (cpp)
**Current usage:**
```cpp
const vector<CommandProto>& availableCommands,
const vector<CommandProto>::const_iterator& fleeCommand,
```
**Status:****CAN REPLACE** - Should use `CommandListSPtr` and indices instead
**Impact:** Flee decision logic could avoid proto conversion
**Priority:** MEDIUM
---
### 5. AIAttackerStrategySelector.cpp/hpp (2 usages)
**Location:** Line 30 in both files
**Current usage:**
```cpp
const vector<CommandProto>& availableCommands) -> AIStrategy
```
**Status:** ⚠️ **PARTIALLY REPLACEABLE** - Currently doesn't use the commands parameter
**Current implementation:**
```cpp
const vector<CommandProto>& /*availableCommands*/) -> AIStrategy {
// Parameter is commented out - not used!
return AIStrategy::DEFAULT;
}
```
**Priority:** LOW (parameter unused, but signature should be consistent)
---
### 6. AICommandEvaluator.hpp (1 usage)
**Location:** Line 27
**Current usage:**
```cpp
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
```
**Status:** ⚠️ **CHECK USAGE** - Type alias, need to check if used
**Priority:** LOW (just a type alias)
---
### 7. AIScoreCalculator.hpp (1 usage)
**Location:** Line 24
**Current usage:**
```cpp
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
```
**Status:** ⚠️ **CHECK USAGE** - Type alias, need to check if used
**Priority:** LOW (just a type alias)
---
### 8. AIWaterCrossingCommandChooser.hpp (1 usage)
**Location:** Line 20
**Current usage:**
```cpp
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
```
**Status:** ⚠️ **CHECK USAGE** - Type alias, need to check if used
**Priority:** LOW (just a type alias)
---
## Key Conversion Points (Entry Points)
### ShardokEngine::GetAvailableCommandProtos()
This method converts the entire command list from `CommandListSPtr` to `vector<CommandProto>`.
**Current flow:**
```
ShardokEngine::GetAvailableCommandsForAIPlayer() → CommandListSPtr
↓ (conversion)
ShardokEngine::GetAvailableCommandProtos() → vector<CommandProto>
AI algorithms (IterativeDeepeningAI, etc.)
```
**Desired flow:**
```
ShardokEngine::GetAvailableCommandsForAIPlayer() → CommandListSPtr
↓ (no conversion!)
AI algorithms use CommandSPtr directly
```
---
## Recommendations by Priority
### HIGH Priority (Performance-critical hot paths)
1. **AICommandFilter.cpp (6 usages)**
- Replace `cmd.GetCommandProto()` with direct accessor methods
- Use `GetActorUnitId()`, `GetTargetRow()`, `GetTargetColumn()`
- Impact: Eliminates 6 proto conversions per filtered command
2. **ShardokAIClient.cpp - GetAvailableCommandProtos calls**
- Replace calls to `GetAvailableCommandProtos()` with `GetAvailableCommandsForAIPlayer()`
- Impact: Eliminates conversion of entire command list
3. **IterativeDeepeningAI**
- Change signature from `vector<CommandProto>` to `CommandListSPtr`
- Impact: Main AI search algorithm avoids proto conversion
### MEDIUM Priority
4. **AIFleeDecisionCalculator**
- Change to use `CommandListSPtr` and indices
- Impact: Flee decision logic avoids proto
5. **ShardokAIClient strategy methods**
- Update signatures to use `CommandListSPtr`
- Cascades to strategy selectors
### LOW Priority
6. **Type aliases**
- Remove unused `using CommandProto` declarations
- Clean up imports
---
## Migration Strategy
### Phase 1: Low-hanging fruit (AICommandFilter) - ✅ **COMPLETED** (PR #4505)
- ✅ Replaced 6 proto conversions with direct accessor calls
- ✅ Added exception handling for missing actor/target data
- ✅ No signature changes needed
- ✅ Immediate performance benefit
- **PR:** #4505
### Phase 2: Entry point (ShardokAIClient)
- Replace `GetAvailableCommandProtos()` calls with `GetAvailableCommandsForAIPlayer()`
- Update method signatures in ShardokAIClient
### Phase 3: Core AI (IterativeDeepeningAI)
- Change IterativeDeepeningAI to accept `CommandListSPtr`
- This is the biggest change but has highest impact
### Phase 4: Supporting systems
- Update AIFleeDecisionCalculator
- Update strategy selectors
- Clean up type aliases
### Phase 5: Validation code
- Keep proto-based validation as-is (uses reflection)
- Consider if validation is still needed in production
---
## Notes
- **MCTS already converted**: The MCTS code path already uses `CommandListSPtr` directly
- **Proto still needed**: For serialization/network communication (not in AI hot path)
- **Validation**: Proto comparison in CheckCommand() should remain (uses proto reflection)
---
## Estimated Impact
**Proto conversions eliminated:** ~20-25 per command choice
**Performance gain:** Eliminates hundreds of allocations per AI decision
**Code simplification:** Removes proto conversion layer from AI
**Before:**
```
Command → Proto → AI Decision
```
**After:**
```
Command → AI Decision (direct)
```
File diff suppressed because it is too large Load Diff
-366
View File
@@ -1,366 +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: 47 of 52 action files (90%) are fully protoless.**
The following 5 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 |
**Note:** `NewRoundAction` is now fully protoless after converting `ChronicleEventGenerator` to return Scala `ChronicleEvent` types directly.
**Deleted Dead Code:**
- `UnaffiliatedHeroMovedAction` - Was never called; `PerformUnaffiliatedHeroesAction.heroMovedResult` constructs `ActionResultC` directly
- `HeroBackstoryUpdateActionGenerator.fromGameState` - Dead method that converted proto to Scala; only `apply(GameState)` is used
**Note**: `PerformReconResolutionAction` and `EndBattleAftermathPhaseAction` are now fully protoless after:
1. Migrating `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` to use Scala `ProvinceView`
2. Adding Scala overload of `ProvinceViewFilter.withdrawnFromProvinceView`
### Estimated Effort (Remaining)
| Component | Lines | Complexity | Blocks |
|-----------|-------|------------|--------|
| `CommandChoiceHelpers` to Scala | ~2000 | High | 4 vassal actions |
| `ResolveBattleAction` refactor | ~500 | High | 1 action (complex) |
| History API updates | ~100 | Low | - |
| **Total Remaining** | **~2600** | | |
**Completed:**
- `ChronicleEventGenerator` converted to return Scala `ChronicleEvent` types directly
### CommandChoiceHelpers Migration Status
Several command selectors have already been converted to use Scala types:
| File | Status | Notes |
|------|--------|-------|
| `AttackCommandChooser.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
| `AlmsCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
| `FoodConsumptionUtils.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `RoundPhase` |
| `MarchSuppliesHelpers.scala` | ✅ **Protoless** | Uses `BattalionT` |
| `CombatUnitSelector.scala` | ✅ **Protoless** | Uses `HeroT`, `BattalionT`, `BattalionType` |
| `ExpandCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `FactionT` |
| `ImproveCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `HeroT` |
| `OrganizeCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `BattalionT`, `BattalionType` |
| `RansomOfferHelpers.scala` | ✅ **Protoless** | Uses Scala `GameState`, `FactionT` |
| `CommandChoiceHelpers.scala` | ❌ Proto | Main entry point, converts to Scala when calling converted selectors |
| `ProvinceGoldSurplusCalculator.scala` | **Partial** | Has both Scala and proto overloads |
| Other selectors | ❌ Proto | Various proto dependencies |
**Pattern**: `CommandChoiceHelpers` currently uses `GameStateConverter.fromProto(gameState)` when calling already-converted selectors like `AlmsCommandSelector` and `AttackCommandChooser`. This allows incremental migration.
**Next Steps**:
1. ~~Convert `ExpandCommandSelector` to Scala types~~ ✅ Done
2. ~~Convert `ImproveCommandSelector` to Scala types~~ ✅ Done
3. ~~Convert `OrganizeCommandSelector` to Scala types~~ ✅ Done (PR #4812)
4. ~~Convert `RansomOfferHelpers` to Scala types~~ ✅ Done (PR #4821)
5. Convert remaining selectors one at a time
6. Update `CommandChoiceHelpers` to accept Scala `GameState` once all selectors are converted
### Progress Summary
| Metric | Value |
|--------|-------|
| Action files fully protoless | 47 / 52 (90%) |
| Proto usages in remaining actions | 32 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
-189
View File
@@ -1,189 +0,0 @@
# Discord + Google OAuth Implementation Plan
## Overview
Replace HTTP Basic Auth with OAuth 2.0 (Discord + Google) for Eagle0. Users authenticate via system browser, receive JWT tokens, and choose their own display names.
## Architecture
```
Unity Client Eagle Server
| |
| 1. Click "Login with Discord/Google" |
| -------------------------------------------------> |
| GetOAuthUrl(provider) -> auth_url + state |
| |
| 2. Open system browser -> OAuth consent |
| 3. User authenticates with provider |
| 4. Redirect to eagle0://auth/callback?code=xxx |
| |
| 5. ExchangeCode(code, state) |
| -------------------------------------------------> |
| Exchange code with provider |
| Fetch user info (id, email, avatar) |
| Create/update user record |
| Issue JWT + refresh token |
| <------------------------------------------------- |
| (jwt, refresh_token, user_info, is_new_user) |
| |
| 6. [If new user] SetDisplayName(name) |
| -------------------------------------------------> |
| |
| 7. Subsequent gRPC calls |
| Authorization: Bearer <jwt> |
| -------------------------------------------------> |
```
## Key Design Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| OAuth flow | System browser + deep link | Secure, supports password managers |
| Code exchange | Eagle server directly | No separate auth service needed |
| JWT signing | RS256 (asymmetric) | Future flexibility for token verification |
| User storage | Protobuf file via Persister | Consistent with existing patterns |
| Token expiry | 7-day access, 30-day refresh | Balance security and gaming UX |
## Implementation Phases
### Phase 1: Proto Definitions & Infrastructure
**New files:**
- `src/main/protobuf/net/eagle0/eagle/api/auth.proto` - Auth API messages
- `src/main/protobuf/net/eagle0/eagle/internal/user.proto` - User storage schema
**Key proto messages:**
```protobuf
// API
GetOAuthUrlRequest/Response // Get OAuth URL to open in browser
ExchangeCodeRequest/Response // Exchange auth code for JWT
SetDisplayNameRequest/Response // Set user's display name
RefreshTokenRequest/Response // Refresh expired access token
// Internal storage
User // user_id, display_name, oauth_identities
UserDatabase // All users + indexes for lookup
```
### Phase 2: Eagle Server Auth Services
**New Scala files:**
- `src/main/scala/net/eagle0/eagle/auth/OAuthConfig.scala` - Discord/Google config from env vars
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - JWT creation/validation (RS256)
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD, display name validation
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth code exchange
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC service implementation
**Modify:**
- `src/main/scala/net/eagle0/eagle/service/AuthorizationInterceptor.scala`
- Replace Basic Auth parsing with JWT validation
- Skip auth for public endpoints (GetOAuthUrl, ExchangeCode, RefreshToken)
- `src/main/scala/net/eagle0/eagle/service/AuthorizationUtils.scala`
- Change context keys from `userName` to `userId` + `displayName`
- `src/main/scala/net/eagle0/eagle/service/Main.scala`
- Wire up new auth services and JWT key loading
### Phase 3: Unity Client OAuth Flow
**New C# files:**
- `Assets/Auth/OAuthManager.cs` - OAuth flow + deep link handling
- `Assets/Auth/TokenStorage.cs` - Secure token persistence
- `Assets/Auth/AuthClient.cs` - gRPC client for auth service
**Modify:**
- `Assets/EagleConnection.cs`
- Replace `AuthInterceptor` (Basic Auth) with `JwtAuthInterceptor` (Bearer token)
- `Assets/ConnectionHandler/ConnectionHandler.cs`
- Replace username/password UI with Discord/Google login buttons
- Add display name setup flow for new users
### Phase 4: Platform Configuration
**Deep link registration:**
- iOS: Add `eagle0://` to CFBundleURLSchemes in Info.plist
- Android: Add intent-filter for `eagle0://auth` in AndroidManifest.xml
- Desktop: Register URL scheme (Windows registry / macOS plist)
**OAuth provider setup:**
1. Discord Developer Portal: Create app, add redirect URI `eagle0://auth/callback`
2. Google Cloud Console: Create OAuth client, add redirect URI
**Environment variables (server):**
```
DISCORD_CLIENT_ID
DISCORD_CLIENT_SECRET
GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET
JWT_PRIVATE_KEY_PATH
JWT_PUBLIC_KEY_PATH
```
### Phase 5: Testing
**Unit tests:**
- `JwtServiceSpec.scala` - Token creation/validation
- `UserServiceSpec.scala` - Display name validation, uniqueness
- `OAuthServiceSpec.scala` - OAuth flow with mocked providers
**Integration tests:**
- Full OAuth flow with mock provider
- JWT validation in AuthorizationInterceptor
- gRPC calls with valid/invalid tokens
**Manual testing:**
- [ ] Discord login (Windows, macOS)
- [ ] Google login (Windows, macOS)
- [ ] Deep link callback works
- [ ] Display name validation
- [ ] Session persistence across restarts
- [ ] Token refresh
## Files Summary
### Create
| File | Purpose |
|------|---------|
| `src/main/protobuf/net/eagle0/eagle/api/auth.proto` | Auth API definitions |
| `src/main/protobuf/net/eagle0/eagle/internal/user.proto` | User storage schema |
| `src/main/scala/net/eagle0/eagle/auth/OAuthConfig.scala` | Provider config |
| `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` | JWT handling |
| `src/main/scala/net/eagle0/eagle/auth/UserService.scala` | User management |
| `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` | OAuth flow |
| `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` | gRPC service |
| `Assets/Auth/OAuthManager.cs` | Unity OAuth manager |
| `Assets/Auth/TokenStorage.cs` | Token storage |
| `Assets/Auth/AuthClient.cs` | Auth gRPC client |
### Modify
| File | Changes |
|------|---------|
| `AuthorizationInterceptor.scala` | Basic Auth -> JWT validation |
| `AuthorizationUtils.scala` | userName -> userId + displayName |
| `Main.scala` | Wire auth services |
| `EagleConnection.cs` | AuthInterceptor -> JwtAuthInterceptor |
| `ConnectionHandler.cs` | Login UI -> OAuth buttons + display name |
### Delete
- nginx htpasswd configuration (no longer needed)
## Security Considerations
1. **State parameter** - CSRF protection in OAuth flow
2. **PKCE** - Consider adding for mobile (enhancement)
3. **Secure storage** - Use Keychain (iOS) / Keystore (Android) for tokens
4. **Token refresh** - 7-day access tokens with 30-day refresh
5. **Rate limiting** - Limit login attempts per IP
## Dependencies to Add
**Scala (MODULE.bazel):**
- JWT library (e.g., `jwt-scala` or `nimbus-jose-jwt`)
- HTTP client (e.g., `sttp` for OAuth requests)
**Unity:**
- Deep linking is built-in (Unity 2021+)
- No additional packages required
## Rollback Plan
Keep Basic Auth code in a feature branch. Both auth methods can coexist during transition via feature flag if needed.
File diff suppressed because it is too large Load Diff
Binary file not shown.
-1
View File
@@ -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
)
-2
View File
@@ -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=
+12 -154
View File
@@ -1,10 +1,9 @@
{
"__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL",
"__INPUT_ARTIFACTS_HASH": -1064460283,
"__RESOLVED_ARTIFACTS_HASH": -1574144850,
"__INPUT_ARTIFACTS_HASH": 571423113,
"__RESOLVED_ARTIFACTS_HASH": 438039003,
"conflict_resolution": {
"com.google.guava:failureaccess:1.0.1": "com.google.guava:failureaccess:1.0.2",
"com.squareup.okio:okio:2.10.0": "com.squareup.okio:okio:3.6.0",
"io.netty:netty-buffer:4.1.110.Final": "io.netty:netty-buffer:4.1.112.Final",
"io.netty:netty-codec-http2:4.1.110.Final": "io.netty:netty-codec-http2:4.1.112.Final",
"io.netty:netty-codec-http:4.1.110.Final": "io.netty:netty-codec-http:4.1.112.Final",
@@ -48,12 +47,6 @@
},
"version": "2.12.7"
},
"com.github.stephenc.jcip:jcip-annotations": {
"shasums": {
"jar": "4fccff8382aafc589962c4edb262f6aa595e34f1e11e61057d1c6a96e8fc7323"
},
"version": "1.0-1"
},
"com.google.android:annotations": {
"shasums": {
"jar": "ba734e1e84c09d615af6a09d33034b4f0442f8772dec120efb376d86a565ae15"
@@ -162,24 +155,6 @@
},
"version": "1.4.2"
},
"com.nimbusds:nimbus-jose-jwt": {
"shasums": {
"jar": "12ae4a3a260095d7aeba2adea7ae396e8b9570db8b7b409e09a824c219cc0444"
},
"version": "9.37.3"
},
"com.squareup.okhttp3:okhttp": {
"shasums": {
"jar": "b1050081b14bb7a3a7e55a4d3ef01b5dcfabc453b4573a4fc019767191d5f4e0"
},
"version": "4.12.0"
},
"com.squareup.okhttp3:okhttp-sse": {
"shasums": {
"jar": "bff4fbcaef7aac2d910d4ff46dafaa4e6d15da127df6bac97216da46943a7d4c"
},
"version": "4.12.0"
},
"com.squareup.okhttp:okhttp": {
"shasums": {
"jar": "88ac9fd1bb51f82bcc664cc1eb9c225c90dc4389d660231b4cc737bebfe7d0aa"
@@ -188,15 +163,9 @@
},
"com.squareup.okio:okio": {
"shasums": {
"jar": "8e63292e5c53bb93c4a6b0c213e79f15990fed250c1340f1c343880e1c9c39b5"
"jar": "a27f091d34aa452e37227e2cfa85809f29012a8ef2501a9b5a125a978e4fcbc1"
},
"version": "3.6.0"
},
"com.squareup.okio:okio-jvm": {
"shasums": {
"jar": "67543f0736fc422ae927ed0e504b98bc5e269fda0d3500579337cb713da28412"
},
"version": "3.6.0"
"version": "2.10.0"
},
"com.thesamet.scalapb:compilerplugin_3": {
"shasums": {
@@ -475,27 +444,15 @@
},
"org.jetbrains.kotlin:kotlin-stdlib": {
"shasums": {
"jar": "55e989c512b80907799f854309f3bc7782c5b3d13932442d0379d5c472711504"
"jar": "b8ab1da5cdc89cb084d41e1f28f20a42bd431538642a5741c52bbfae3fa3e656"
},
"version": "1.9.10"
"version": "1.4.20"
},
"org.jetbrains.kotlin:kotlin-stdlib-common": {
"shasums": {
"jar": "cde3341ba18a2ba262b0b7cf6c55b20c90e8d434e42c9a13e6a3f770db965a88"
"jar": "a7112c9b3cefee418286c9c9372f7af992bd1e6e030691d52f60cb36dbec8320"
},
"version": "1.9.10"
},
"org.jetbrains.kotlin:kotlin-stdlib-jdk7": {
"shasums": {
"jar": "ac6361bf9ad1ed382c2103d9712c47cdec166232b4903ed596e8876b0681c9b7"
},
"version": "1.9.10"
},
"org.jetbrains.kotlin:kotlin-stdlib-jdk8": {
"shasums": {
"jar": "a4c74d94d64ce1abe53760fe0389dd941f6fc558d0dab35e47c085a11ec80f28"
},
"version": "1.9.10"
"version": "1.4.20"
},
"org.jetbrains:annotations": {
"shasums": {
@@ -822,26 +779,12 @@
"org.checkerframework:checker-qual",
"org.ow2.asm:asm"
],
"com.nimbusds:nimbus-jose-jwt": [
"com.github.stephenc.jcip:jcip-annotations"
],
"com.squareup.okhttp3:okhttp": [
"com.squareup.okio:okio",
"org.jetbrains.kotlin:kotlin-stdlib-jdk8"
],
"com.squareup.okhttp3:okhttp-sse": [
"com.squareup.okhttp3:okhttp",
"org.jetbrains.kotlin:kotlin-stdlib-jdk8"
],
"com.squareup.okhttp:okhttp": [
"com.squareup.okio:okio"
],
"com.squareup.okio:okio": [
"com.squareup.okio:okio-jvm"
],
"com.squareup.okio:okio-jvm": [
"org.jetbrains.kotlin:kotlin-stdlib-common",
"org.jetbrains.kotlin:kotlin-stdlib-jdk8"
"org.jetbrains.kotlin:kotlin-stdlib",
"org.jetbrains.kotlin:kotlin-stdlib-common"
],
"com.thesamet.scalapb:compilerplugin_3": [
"com.google.protobuf:protobuf-java",
@@ -1049,13 +992,6 @@
"org.jetbrains.kotlin:kotlin-stdlib-common",
"org.jetbrains:annotations"
],
"org.jetbrains.kotlin:kotlin-stdlib-jdk7": [
"org.jetbrains.kotlin:kotlin-stdlib"
],
"org.jetbrains.kotlin:kotlin-stdlib-jdk8": [
"org.jetbrains.kotlin:kotlin-stdlib",
"org.jetbrains.kotlin:kotlin-stdlib-jdk7"
],
"org.json4s:json4s-ast_3": [
"org.scala-lang:scala3-library_3"
],
@@ -1370,9 +1306,6 @@
"com.fasterxml.jackson.databind.type",
"com.fasterxml.jackson.databind.util"
],
"com.github.stephenc.jcip:jcip-annotations": [
"net.jcip.annotations"
],
"com.google.android:annotations": [
"android.annotation"
],
@@ -1518,61 +1451,6 @@
"com.google.truth:truth": [
"com.google.common.truth"
],
"com.nimbusds:nimbus-jose-jwt": [
"com.nimbusds.jose",
"com.nimbusds.jose.crypto",
"com.nimbusds.jose.crypto.bc",
"com.nimbusds.jose.crypto.factories",
"com.nimbusds.jose.crypto.impl",
"com.nimbusds.jose.crypto.opts",
"com.nimbusds.jose.crypto.utils",
"com.nimbusds.jose.jca",
"com.nimbusds.jose.jwk",
"com.nimbusds.jose.jwk.gen",
"com.nimbusds.jose.jwk.source",
"com.nimbusds.jose.mint",
"com.nimbusds.jose.proc",
"com.nimbusds.jose.produce",
"com.nimbusds.jose.shaded.gson",
"com.nimbusds.jose.shaded.gson.annotations",
"com.nimbusds.jose.shaded.gson.internal",
"com.nimbusds.jose.shaded.gson.internal.bind",
"com.nimbusds.jose.shaded.gson.internal.bind.util",
"com.nimbusds.jose.shaded.gson.internal.reflect",
"com.nimbusds.jose.shaded.gson.internal.sql",
"com.nimbusds.jose.shaded.gson.reflect",
"com.nimbusds.jose.shaded.gson.stream",
"com.nimbusds.jose.util",
"com.nimbusds.jose.util.cache",
"com.nimbusds.jose.util.events",
"com.nimbusds.jose.util.health",
"com.nimbusds.jwt",
"com.nimbusds.jwt.proc",
"com.nimbusds.jwt.util"
],
"com.squareup.okhttp3:okhttp": [
"okhttp3",
"okhttp3.internal",
"okhttp3.internal.authenticator",
"okhttp3.internal.cache",
"okhttp3.internal.cache2",
"okhttp3.internal.concurrent",
"okhttp3.internal.connection",
"okhttp3.internal.http",
"okhttp3.internal.http1",
"okhttp3.internal.http2",
"okhttp3.internal.io",
"okhttp3.internal.platform",
"okhttp3.internal.platform.android",
"okhttp3.internal.proxy",
"okhttp3.internal.publicsuffix",
"okhttp3.internal.tls",
"okhttp3.internal.ws"
],
"com.squareup.okhttp3:okhttp-sse": [
"okhttp3.internal.sse",
"okhttp3.sse"
],
"com.squareup.okhttp:okhttp": [
"com.squareup.okhttp",
"com.squareup.okhttp.internal",
@@ -1581,7 +1459,7 @@
"com.squareup.okhttp.internal.io",
"com.squareup.okhttp.internal.tls"
],
"com.squareup.okio:okio-jvm": [
"com.squareup.okio:okio": [
"okio",
"okio.internal"
],
@@ -1936,7 +1814,6 @@
"kotlin.annotation",
"kotlin.collections",
"kotlin.collections.builders",
"kotlin.collections.jdk8",
"kotlin.collections.unsigned",
"kotlin.comparisons",
"kotlin.concurrent",
@@ -1945,36 +1822,24 @@
"kotlin.coroutines.cancellation",
"kotlin.coroutines.intrinsics",
"kotlin.coroutines.jvm.internal",
"kotlin.enums",
"kotlin.experimental",
"kotlin.internal",
"kotlin.internal.jdk7",
"kotlin.internal.jdk8",
"kotlin.io",
"kotlin.io.encoding",
"kotlin.io.path",
"kotlin.jdk7",
"kotlin.js",
"kotlin.jvm",
"kotlin.jvm.functions",
"kotlin.jvm.internal",
"kotlin.jvm.internal.markers",
"kotlin.jvm.internal.unsafe",
"kotlin.jvm.jdk8",
"kotlin.jvm.optionals",
"kotlin.math",
"kotlin.properties",
"kotlin.random",
"kotlin.random.jdk8",
"kotlin.ranges",
"kotlin.reflect",
"kotlin.sequences",
"kotlin.streams.jdk8",
"kotlin.system",
"kotlin.text",
"kotlin.text.jdk8",
"kotlin.time",
"kotlin.time.jdk8"
"kotlin.time"
],
"org.jetbrains:annotations": [
"org.intellij.lang.annotations",
@@ -2387,7 +2252,6 @@
"com.fasterxml.jackson.core:jackson-annotations",
"com.fasterxml.jackson.core:jackson-core",
"com.fasterxml.jackson.core:jackson-databind",
"com.github.stephenc.jcip:jcip-annotations",
"com.google.android:annotations",
"com.google.api.grpc:proto-google-common-protos",
"com.google.auth:google-auth-library-credentials",
@@ -2406,12 +2270,8 @@
"com.google.protobuf:protobuf-java",
"com.google.re2j:re2j",
"com.google.truth:truth",
"com.nimbusds:nimbus-jose-jwt",
"com.squareup.okhttp3:okhttp",
"com.squareup.okhttp3:okhttp-sse",
"com.squareup.okhttp:okhttp",
"com.squareup.okio:okio",
"com.squareup.okio:okio-jvm",
"com.thesamet.scalapb:compilerplugin_3",
"com.thesamet.scalapb:lenses_3",
"com.thesamet.scalapb:protoc-bridge_2.13",
@@ -2464,8 +2324,6 @@
"org.hamcrest:hamcrest-core",
"org.jetbrains.kotlin:kotlin-stdlib",
"org.jetbrains.kotlin:kotlin-stdlib-common",
"org.jetbrains.kotlin:kotlin-stdlib-jdk7",
"org.jetbrains.kotlin:kotlin-stdlib-jdk8",
"org.jetbrains:annotations",
"org.json4s:json4s-ast_3",
"org.json4s:json4s-core_3",
-132
View File
@@ -1,132 +0,0 @@
events {
worker_connections 1024;
}
http {
# Logging
log_format grpc_json escape=json '{'
'"time":"$time_iso8601",'
'"client":"$remote_addr",'
'"uri":"$uri",'
'"status":$status,'
'"grpc_status":"$sent_http_grpc_status",'
'"request_time":$request_time,'
'"upstream_time":"$upstream_response_time"'
'}';
access_log /var/log/nginx/access.log grpc_json;
error_log /var/log/nginx/error.log warn;
# Rate limiting zone
limit_req_zone $binary_remote_addr zone=grpc_limit:10m rate=100r/s;
# Docker DNS resolver - re-resolve hostnames every 10s
# This prevents stale IP caching when containers restart
resolver 127.0.0.11 valid=10s ipv6=off;
# Upstream for Eagle gRPC server
upstream eagle_grpc {
server eagle:40032;
keepalive 100;
}
# HTTP server for Let's Encrypt challenge and redirect
server {
listen 80;
server_name prod.eagle0.net;
# Let's Encrypt challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect all other HTTP to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS server for gRPC
server {
listen 443 ssl;
http2 on;
server_name prod.eagle0.net;
# SSL certificates (managed by certbot)
ssl_certificate /etc/letsencrypt/live/prod.eagle0.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/prod.eagle0.net/privkey.pem;
# SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# gRPC proxy for Eagle service
location /net.eagle0.eagle.api.Eagle {
# Rate limiting
limit_req zone=grpc_limit burst=50 nodelay;
# gRPC proxy
grpc_pass grpc://eagle_grpc;
# Timeouts for long-running streams
grpc_read_timeout 1200s;
grpc_send_timeout 1200s;
grpc_socket_keepalive on;
# Error handling
error_page 502 = /error502grpc;
}
# gRPC proxy for Auth service
location /net.eagle0.eagle.api.auth.Auth {
# Rate limiting
limit_req zone=grpc_limit burst=50 nodelay;
# gRPC proxy
grpc_pass grpc://eagle_grpc;
# Timeouts
grpc_read_timeout 30s;
grpc_send_timeout 30s;
# Error handling
error_page 502 = /error502grpc;
}
# OAuth callback endpoint (proxied to Eagle's HTTP handler)
location /oauth/callback {
proxy_pass http://eagle:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Admin server proxy
location /admin/ {
proxy_pass http://admin:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Health check endpoint
location /health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
# gRPC error handling
location = /error502grpc {
internal;
default_type application/grpc;
add_header grpc-status 14;
add_header grpc-message "unavailable";
return 204;
}
}
}
+2 -3
View File
@@ -3,8 +3,7 @@
set -euxo pipefail
/bin/echo "building darwin bundle"
bazel build --config=mactools @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle
ZIP_LOCATION=$(bazel cquery --config=mactools --output=files @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle 2>/dev/null)
/usr/bin/unzip -o $ZIP_LOCATION -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
bazel build --noincompatible_enable_cc_toolchain_resolution @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle
/usr/bin/unzip -o bazel-bin/external/net_eagle0_unity_godice/darwin/framework/DarwinGodiceBundle.zip -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
/usr/bin/plutil -convert xml1 src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/DarwinGodiceBundle.bundle/Contents/Info.plist
+2 -3
View File
@@ -5,9 +5,8 @@ set -euxo pipefail
/bin/echo "build plugins"
/bin/echo "building darwin bundle"
bazel build --config=mactools @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle
ZIP_LOCATION=$(bazel cquery --config=mactools --output=files @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle 2>/dev/null)
/usr/bin/unzip -o $ZIP_LOCATION -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
bazel build --noincompatible_enable_cc_toolchain_resolution @net_eagle0_unity_godice//darwin/framework:DarwinGodiceBundle
/usr/bin/unzip -o bazel-bin/external/net_eagle0_unity_godice/darwin/framework/DarwinGodiceBundle.zip -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
/usr/bin/plutil -convert xml1 src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/DarwinGodiceBundle.bundle/Contents/Info.plist
+2 -4
View File
@@ -1,10 +1,8 @@
#!/usr/bin/env bash
curl -L "https://docs.google.com/spreadsheets/d/1pv-WMXReccddPwev_YG9IXEGznuGHrYjNNEZ0Rb-ZhM/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/shardok/settings.tsv
curl -L "https://docs.google.com/spreadsheets/d/1p6I5nUMcoAPHIcqikVgbBCFVnqN9dpOEVClbS_wOI7M/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/eagle/settings.tsv
curl -L "https://docs.google.com/spreadsheets/d/1pv-WMXReccddPwev_YG9IXEGznuGHrYjNNEZ0Rb-ZhM/export?gid=0&format=tsv" > src/main/resources/net/eagle0/shardok/settings.tsv
curl -L "https://docs.google.com/spreadsheets/d/1p6I5nUMcoAPHIcqikVgbBCFVnqN9dpOEVClbS_wOI7M/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/settings.tsv
bazel run //src/main/go/net/eagle0/build/settings_generator:settings_generator -- \
${PWD}/src/main/resources/net/eagle0/eagle/settings.tsv \
${PWD}/src/main/scala/net/eagle0/eagle/library/settings/
bazel run gazelle
+4 -4
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env bash
curl -L "https://docs.google.com/spreadsheets/d/1DHEsiv4cY4gE6AX3sVH82K__mpBD1aznIYCQwQxA_F0/export?gid=0&format=tsv" | tr -d '\r' > /tmp/names.tsv
curl -L "https://docs.google.com/spreadsheets/d/1DHEsiv4cY4gE6AX3sVH82K__mpBD1aznIYCQwQxA_F0/export?gid=0&format=tsv" > /tmp/names.tsv
bazel run //src/main/scala/net/eagle0/util:name_list_checker -- /tmp/names.tsv > src/main/resources/net/eagle0/names.tsv
bazel run //src/main/scala/net/eagle0/util:name_list_json_maker -- /tmp/names.tsv > src/main/resources/net/eagle0/names.json
curl -L "https://docs.google.com/spreadsheets/d/1NhvG73HKyVE36yGpkV2oJiSIXoNqQOYTr5ArLnucYL0/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/shardok/battalionTypes.tsv
curl -L "https://docs.google.com/spreadsheets/d/1pNWiyxIks2wJ1v7jRLFD24zrKHG2AfhC-nkWmQKQGN4/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/eagle/heroes.tsv
curl -L "https://docs.google.com/spreadsheets/d/1RUguq5eAQprsZwOOqiCc-1dg4Urc_6iJ6awZsFU4MeI/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/eagle/beasts.tsv
curl -L "https://docs.google.com/spreadsheets/d/1NhvG73HKyVE36yGpkV2oJiSIXoNqQOYTr5ArLnucYL0/export?gid=0&format=tsv" > src/main/resources/net/eagle0/shardok/battalionTypes.tsv
curl -L "https://docs.google.com/spreadsheets/d/1pNWiyxIks2wJ1v7jRLFD24zrKHG2AfhC-nkWmQKQGN4/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/heroes.tsv
curl -L "https://docs.google.com/spreadsheets/d/1RUguq5eAQprsZwOOqiCc-1dg4Urc_6iJ6awZsFU4MeI/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/beasts.tsv
#curl -L "https://docs.google.com/spreadsheets/d/1Z-60cJ_N1IasvqpVb5awKEkIYznEeR2IZSdli47oW88/export?gid=0&format=tsv" > src/main/resources/net/eagle0/eagle/province_map.tsv
${PWD}/scripts/dlSettings.sh
-473
View File
@@ -1,473 +0,0 @@
#!/bin/bash
#
# generate_changelog.sh
#
# Generates a weekly changelog from merged PRs, uses Claude to create a synopsis,
# and sends an HTML email via Fastmail JMAP API.
#
# Usage: ./scripts/generate_changelog.sh [--dry-run]
#
# Configuration files (in ~/.config/eagle0/):
# fastmail_token - API token (required)
# changelog_recipient - Email addresses, one per line (optional, defaults to sender)
#
# To set up:
# mkdir -p ~/.config/eagle0
# echo 'your-token' > ~/.config/eagle0/fastmail_token
# chmod 600 ~/.config/eagle0/fastmail_token
#
# # Optional: configure recipients (one per line, # for comments)
# cat > ~/.config/eagle0/changelog_recipient << EOF
# alice@example.com
# bob@example.com
# EOF
#
# The script tracks its last run using a git tag 'changelog-last-run'.
# On first run (no tag), it defaults to the previous Friday at 4pm.
set -euo pipefail
# Ensure homebrew binaries are in PATH
export PATH="/opt/homebrew/bin:$PATH"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TAG_NAME="changelog-last-run"
DRY_RUN=false
FASTMAIL_API="https://api.fastmail.com/jmap/api/"
CONFIG_DIR="$HOME/.config/eagle0"
TOKEN_FILE="$CONFIG_DIR/fastmail_token"
RECIPIENT_FILE="$CONFIG_DIR/changelog_recipient"
# Load API token from file or environment
load_api_token() {
# Environment variable takes precedence
if [[ -n "${FASTMAIL_API_TOKEN:-}" ]]; then
return 0
fi
# Try loading from config file
if [[ -f "$TOKEN_FILE" ]]; then
FASTMAIL_API_TOKEN=$(cat "$TOKEN_FILE" | tr -d '[:space:]')
if [[ -n "$FASTMAIL_API_TOKEN" ]]; then
echo "Loaded API token from $TOKEN_FILE"
export FASTMAIL_API_TOKEN
return 0
fi
fi
return 1
}
# Load recipient emails from config file (one per line)
# Returns JSON array fragment like: {"email": "a@b.com"}, {"email": "c@d.com"}
load_recipients_json() {
local recipients=""
if [[ -f "$RECIPIENT_FILE" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
# Skip empty lines and comments
line=$(echo "$line" | tr -d '[:space:]')
[[ -z "$line" || "$line" == \#* ]] && continue
if [[ -n "$recipients" ]]; then
recipients="$recipients, "
fi
recipients="$recipients{\"email\": \"$line\"}"
done < "$RECIPIENT_FILE"
fi
echo "$recipients"
}
# Get human-readable list of recipients
load_recipients_display() {
if [[ -f "$RECIPIENT_FILE" ]]; then
grep -v '^#' "$RECIPIENT_FILE" | grep -v '^[[:space:]]*$' | tr '\n' ', ' | sed 's/, $//'
fi
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--dry-run)
DRY_RUN=true
shift
;;
*)
echo "Unknown option: $1"
echo "Usage: $0 [--dry-run]"
exit 1
;;
esac
done
cd "$REPO_ROOT"
# Get the cutoff date - either from tag or previous Friday 4pm
get_cutoff_date() {
# Try to get the date from the tag
if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then
# Get the commit date of the tagged commit
git log -1 --format="%aI" "$TAG_NAME"
else
# Calculate previous Friday at 4pm
# Get current day of week (1=Monday, 7=Sunday)
local dow=$(date +%u)
local days_since_friday
if [[ $dow -ge 5 ]]; then
# Friday (5), Saturday (6), or Sunday (7)
days_since_friday=$((dow - 5))
else
# Monday (1) through Thursday (4)
days_since_friday=$((dow + 2))
fi
# Get previous Friday at 4pm in ISO format
if [[ "$(uname)" == "Darwin" ]]; then
date -v-"${days_since_friday}d" -v16H -v0M -v0S +"%Y-%m-%dT%H:%M:%S%z"
else
date -d "$days_since_friday days ago 16:00:00" --iso-8601=seconds
fi
fi
}
# Fetch merged PRs since the cutoff date
fetch_merged_prs() {
local since_date="$1"
local output_file="$2"
echo "Fetching PRs merged since: $since_date"
# Use gh to search for merged PRs
gh pr list \
--state merged \
--base main \
--json number,title,body,mergedAt,author \
--jq ".[] | select(.mergedAt >= \"$since_date\")" \
> "$output_file.json"
# Format the output nicely
echo "# Merged PRs since $since_date" > "$output_file"
echo "" >> "$output_file"
# Process each PR
jq -r '
"## PR #\(.number): \(.title)\n" +
"Author: \(.author.login)\n" +
"Merged: \(.mergedAt)\n\n" +
"### Description\n" +
(.body // "(No description)") +
"\n\n---\n"
' "$output_file.json" >> "$output_file"
# Count PRs
local pr_count=$(jq -s 'length' "$output_file.json")
echo "Found $pr_count merged PRs"
rm -f "$output_file.json"
if [[ $pr_count -eq 0 ]]; then
echo "No PRs found since $since_date"
return 1
fi
return 0
}
# Generate synopsis using Claude
generate_synopsis() {
local input_file="$1"
local output_file="$2"
echo "Generating synopsis with Claude..."
# Create a prompt file to avoid shell escaping issues
local prompt_file="/tmp/eagle0_prompt_$$.txt"
# Get repo URL for PR links
local repo_url=$(gh repo view --json url -q '.url')
cat > "$prompt_file" <<PROMPT_HEADER
You are summarizing changes for a weekly engineering update email.
Read the following list of merged PRs and create a concise synopsis grouped by theme/feature/area of the codebase.
Structure:
1. <h1> title (e.g., "Eagle0 Weekly Update")
2. <h2>BLUF</h2> (Bottom Line Up Front) - A short prose paragraph (2-4 sentences) highlighting the 1-3 most important changes this week and what to look for when testing. This should be conversational and help readers quickly understand what matters most.
3. Synopsis sections (<h2> headings with bullet point summaries)
4. <hr> divider
5. <h2>PR Details</h2> with the same groupings, but smaller (<h3> headings) and listing PR links
- Format each PR as: <a href="${repo_url}/pull/NUMBER">#NUMBER</a>: Title
Guidelines for the SYNOPSIS sections:
- Group related changes together under clear headings (use <h2> tags)
- Use bullet points (<ul><li>) for individual changes
- Highlight any significant new features, breaking changes, or important fixes
- Keep the tone professional but accessible
- Don't include PR numbers in the synopsis - focus on what changed and why it matters
IMPORTANT: Output valid HTML that can be used directly in an email body. Do NOT wrap in \`\`\`html code blocks - just output the raw HTML.
Here are the merged PRs:
PROMPT_HEADER
cat "$input_file" >> "$prompt_file"
echo "" >> "$prompt_file"
echo "Generate the synopsis now:" >> "$prompt_file"
# Use Claude CLI to generate the synopsis, wrapped in proper HTML with charset
local raw_output="/tmp/eagle0_raw_$$.html"
cat "$prompt_file" | claude --print > "$raw_output"
# Wrap in HTML document with UTF-8 charset
cat > "$output_file" <<'HTML_HEAD'
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
HTML_HEAD
cat "$raw_output" >> "$output_file"
echo "</body></html>" >> "$output_file"
rm -f "$prompt_file" "$raw_output"
echo "Synopsis generated at: $output_file"
}
# Get Fastmail session info (account ID, identity ID, drafts mailbox ID)
get_fastmail_session() {
echo "Fetching Fastmail session info..." >&2
# Get session
local session=$(curl -s \
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
"https://api.fastmail.com/jmap/session")
# Extract account ID (first account)
FASTMAIL_ACCOUNT_ID=$(echo "$session" | jq -r '.primaryAccounts["urn:ietf:params:jmap:mail"]')
if [[ -z "$FASTMAIL_ACCOUNT_ID" || "$FASTMAIL_ACCOUNT_ID" == "null" ]]; then
echo "Error: Could not get Fastmail account ID. Check your API token." >&2
return 1
fi
echo "Account ID: $FASTMAIL_ACCOUNT_ID" >&2
# Get identity ID
local identity_response=$(curl -s \
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST \
-d "{
\"using\": [\"urn:ietf:params:jmap:core\", \"urn:ietf:params:jmap:mail\", \"urn:ietf:params:jmap:submission\"],
\"methodCalls\": [
[\"Identity/get\", {\"accountId\": \"$FASTMAIL_ACCOUNT_ID\"}, \"0\"]
]
}" \
"$FASTMAIL_API")
FASTMAIL_IDENTITY_ID=$(echo "$identity_response" | jq -r '.methodResponses[0][1].list[0].id')
FASTMAIL_FROM_EMAIL=$(echo "$identity_response" | jq -r '.methodResponses[0][1].list[0].email')
if [[ -z "$FASTMAIL_IDENTITY_ID" || "$FASTMAIL_IDENTITY_ID" == "null" ]]; then
echo "Error: Could not get Fastmail identity ID." >&2
return 1
fi
echo "Identity ID: $FASTMAIL_IDENTITY_ID (${FASTMAIL_FROM_EMAIL})" >&2
# Get drafts mailbox ID
local mailbox_response=$(curl -s \
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST \
-d "{
\"using\": [\"urn:ietf:params:jmap:core\", \"urn:ietf:params:jmap:mail\"],
\"methodCalls\": [
[\"Mailbox/query\", {\"accountId\": \"$FASTMAIL_ACCOUNT_ID\", \"filter\": {\"role\": \"drafts\"}}, \"0\"]
]
}" \
"$FASTMAIL_API")
FASTMAIL_DRAFTS_ID=$(echo "$mailbox_response" | jq -r '.methodResponses[0][1].ids[0]')
if [[ -z "$FASTMAIL_DRAFTS_ID" || "$FASTMAIL_DRAFTS_ID" == "null" ]]; then
echo "Error: Could not get Fastmail drafts mailbox ID." >&2
return 1
fi
echo "Drafts mailbox ID: $FASTMAIL_DRAFTS_ID" >&2
return 0
}
# Send email via Fastmail JMAP API
send_email_fastmail() {
local synopsis_file="$1"
local recipients_json="$2" # JSON array fragment: {"email": "a@b.com"}, {"email": "c@d.com"}
local subject="Eagle0 Weekly Changelog - $(date +%Y-%m-%d)"
local html_body=$(cat "$synopsis_file" | jq -Rs .)
echo "Sending email via Fastmail JMAP API..."
# Create the email and send it in one request
local response=$(curl -s \
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST \
-d "{
\"using\": [
\"urn:ietf:params:jmap:core\",
\"urn:ietf:params:jmap:mail\",
\"urn:ietf:params:jmap:submission\"
],
\"methodCalls\": [
[\"Email/set\", {
\"accountId\": \"$FASTMAIL_ACCOUNT_ID\",
\"create\": {
\"draft\": {
\"from\": [{\"email\": \"$FASTMAIL_FROM_EMAIL\"}],
\"to\": [$recipients_json],
\"subject\": \"$subject\",
\"mailboxIds\": {\"$FASTMAIL_DRAFTS_ID\": true},
\"keywords\": {\"\$draft\": true},
\"htmlBody\": [{\"partId\": \"body\", \"type\": \"text/html\"}],
\"bodyValues\": {
\"body\": {
\"charset\": \"utf-8\",
\"value\": $html_body
}
}
}
}
}, \"0\"],
[\"EmailSubmission/set\", {
\"accountId\": \"$FASTMAIL_ACCOUNT_ID\",
\"onSuccessDestroyEmail\": [\"#sendIt\"],
\"create\": {
\"sendIt\": {
\"emailId\": \"#draft\",
\"identityId\": \"$FASTMAIL_IDENTITY_ID\"
}
}
}, \"1\"]
]
}" \
"$FASTMAIL_API")
# Check for errors
local error=$(echo "$response" | jq -r '.methodResponses[0][1].notCreated.draft.description // empty')
if [[ -n "$error" ]]; then
echo "Error creating email: $error" >&2
echo "Full response: $response" >&2
return 1
fi
local send_error=$(echo "$response" | jq -r '.methodResponses[1][1].notCreated.sendIt.description // empty')
if [[ -n "$send_error" ]]; then
echo "Error sending email: $send_error" >&2
echo "Full response: $response" >&2
return 1
fi
echo "Email sent successfully"
}
# Update the tag to mark this run
update_tag() {
echo "Updating $TAG_NAME tag..."
# Delete existing tag if present
git tag -d "$TAG_NAME" 2>/dev/null || true
git push origin --delete "$TAG_NAME" 2>/dev/null || true
# Create new tag at HEAD
git tag "$TAG_NAME"
git push origin "$TAG_NAME"
echo "Tag updated to current HEAD"
}
# Main
main() {
echo "=== Eagle0 Weekly Changelog Generator ==="
echo ""
# Load API token (only required for actual send)
if [[ "$DRY_RUN" != "true" ]]; then
if ! load_api_token; then
echo "Error: No Fastmail API token found."
echo ""
echo "To create a token:"
echo "1. Go to Fastmail Settings -> Password & Security -> API tokens"
echo "2. Create a new token with 'Email submission' scope"
echo "3. Save it using one of these methods:"
echo ""
echo " Option A (recommended): Store in config file"
echo " mkdir -p ~/.config/eagle0"
echo " echo 'your-token' > ~/.config/eagle0/fastmail_token"
echo " chmod 600 ~/.config/eagle0/fastmail_token"
echo ""
echo " Option B: Set environment variable"
echo " export FASTMAIL_API_TOKEN='your-token'"
exit 1
fi
fi
# Get cutoff date
local cutoff_date=$(get_cutoff_date)
echo "Cutoff date: $cutoff_date"
# Create temp files
local pr_file="/tmp/eagle0_prs_$(date +%s).md"
local synopsis_file="/tmp/eagle0_synopsis_$(date +%s).html"
# Fetch PRs
if ! fetch_merged_prs "$cutoff_date" "$pr_file"; then
echo "No changes to report. Exiting."
exit 0
fi
echo ""
echo "PR details saved to: $pr_file"
# Generate synopsis
generate_synopsis "$pr_file" "$synopsis_file"
if [[ "$DRY_RUN" == "true" ]]; then
echo ""
echo "=== DRY RUN - Synopsis content ==="
cat "$synopsis_file"
echo ""
echo "=== DRY RUN - Skipping email send and tag update ==="
else
# Get Fastmail session info
if ! get_fastmail_session; then
echo "Failed to get Fastmail session info. Exiting."
exit 1
fi
# Determine recipients (from config file, or default to sender)
local recipients_json=$(load_recipients_json)
if [[ -z "$recipients_json" ]]; then
recipients_json="{\"email\": \"$FASTMAIL_FROM_EMAIL\"}"
echo "No recipients configured, sending to self ($FASTMAIL_FROM_EMAIL)"
else
local recipients_display=$(load_recipients_display)
echo "Sending to: $recipients_display"
fi
# Send email
send_email_fastmail "$synopsis_file" "$recipients_json"
# Update tag for next run
update_tag
fi
echo ""
echo "Done!"
echo "PR details: $pr_file"
echo "Synopsis: $synopsis_file"
}
main
-19
View File
@@ -1,19 +0,0 @@
#!/bin/bash
# Pre-commit hook wrapper for gazelle that fails if files are modified.
# This ensures BUILD files are in canonical format before committing.
set -e
# Run gazelle
bazel run //:gazelle 2>/dev/null
# Check if any BUILD files were modified
if ! git diff --quiet -- '*.bazel' '**/BUILD' 'WORKSPACE*'; then
echo ""
echo "ERROR: gazelle modified BUILD files. Please stage the changes and retry:"
echo ""
git diff --name-only -- '*.bazel' '**/BUILD' 'WORKSPACE*'
echo ""
echo "Run: git add -u && git commit"
exit 1
fi
-149
View File
@@ -1,149 +0,0 @@
#!/bin/bash
#
# Setup script for Eagle0 production droplet
# Run this on a fresh DigitalOcean droplet (Ubuntu 24.04)
#
# Usage: curl -sSL https://raw.githubusercontent.com/nolen777/eagle0/main/scripts/setup_droplet.sh | sudo bash
#
set -euo pipefail
DOMAIN="${DOMAIN:-eagle0.net}"
DEPLOY_USER="${DEPLOY_USER:-deploy}"
APP_DIR="/opt/eagle0"
echo "=== Eagle0 Production Server Setup ==="
echo "Domain: ${DOMAIN}"
echo "Deploy user: ${DEPLOY_USER}"
echo ""
# Check if running as root
if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root (use sudo)"
exit 1
fi
echo "=== Updating system ==="
apt-get update
apt-get upgrade -y
echo "=== Installing Docker ==="
if ! command -v docker &> /dev/null; then
curl -fsSL https://get.docker.com | sh
systemctl enable docker
systemctl start docker
else
echo "Docker already installed"
fi
echo "=== Installing Docker Compose plugin ==="
apt-get install -y docker-compose-plugin
echo "=== Installing additional utilities ==="
apt-get install -y \
curl \
wget \
git \
netcat-openbsd \
jq \
htop \
unattended-upgrades
echo "=== Configuring automatic security updates ==="
cat > /etc/apt/apt.conf.d/20auto-upgrades << 'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
EOF
echo "=== Creating deploy user ==="
if ! id "${DEPLOY_USER}" &>/dev/null; then
useradd -m -s /bin/bash -G docker "${DEPLOY_USER}"
mkdir -p "/home/${DEPLOY_USER}/.ssh"
chmod 700 "/home/${DEPLOY_USER}/.ssh"
chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "/home/${DEPLOY_USER}/.ssh"
echo ""
echo "*** IMPORTANT: Add your SSH public key to /home/${DEPLOY_USER}/.ssh/authorized_keys ***"
echo ""
else
echo "User ${DEPLOY_USER} already exists"
# Ensure user is in docker group
usermod -aG docker "${DEPLOY_USER}"
fi
echo "=== Creating application directory ==="
mkdir -p "${APP_DIR}"/{nginx,certbot/conf,certbot/www,saves}
chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "${APP_DIR}"
echo "=== Configuring Docker registry authentication ==="
echo ""
echo "*** IMPORTANT: Run the following command to authenticate with DigitalOcean Container Registry: ***"
echo " docker login registry.digitalocean.com"
echo ""
echo "=== Creating systemd service ==="
cat > /etc/systemd/system/eagle0.service << EOF
[Unit]
Description=Eagle0 Game Servers
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=${APP_DIR}
ExecStart=/usr/bin/docker compose -f docker-compose.prod.yml up -d
ExecStop=/usr/bin/docker compose -f docker-compose.prod.yml down
User=${DEPLOY_USER}
Group=${DEPLOY_USER}
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable eagle0
echo "=== Configuring firewall (UFW) ==="
if ! command -v ufw &> /dev/null; then
apt-get install -y ufw
fi
ufw default deny incoming
ufw default allow outgoing
ufw allow ssh
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
echo "=== Setting up log rotation ==="
cat > /etc/logrotate.d/eagle0 << EOF
/var/log/eagle0/*.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
create 0640 ${DEPLOY_USER} ${DEPLOY_USER}
sharedscripts
}
EOF
mkdir -p /var/log/eagle0
chown "${DEPLOY_USER}:${DEPLOY_USER}" /var/log/eagle0
echo ""
echo "=== Setup Complete ==="
echo ""
echo "Next steps:"
echo "1. Add SSH public key to /home/${DEPLOY_USER}/.ssh/authorized_keys"
echo "2. Copy docker-compose.prod.yml to ${APP_DIR}/"
echo "3. Copy nginx/nginx.conf to ${APP_DIR}/nginx/"
echo "4. Create .env file in ${APP_DIR}/ with OPENAI_API_KEY"
echo "5. Run: docker login registry.digitalocean.com"
echo "6. Get SSL certificate: (see init_ssl.sh)"
echo "7. Start services: systemctl start eagle0"
echo ""
echo "Server IP: $(curl -s ifconfig.me)"
echo ""
@@ -26,13 +26,6 @@ namespace fs = std::filesystem;
static string rLocation;
auto rloc(const string& execPath) -> string {
// First check for environment variable override for Docker deployment
const char* resourcesPath = getenv("SHARDOK_RESOURCES_PATH");
if (resourcesPath != nullptr) {
return ""; // Return empty so StaticShardokFilesDirectory uses env var directly
}
// Fall back to Bazel runfiles for development
string error;
const std::unique_ptr<Runfiles> runfiles(Runfiles::Create(execPath, &error));
@@ -65,14 +58,10 @@ auto FilesystemUtils::FileExistsAtPath(const string& path) -> bool { return fs::
auto FilesystemUtils::StaticEagle0FilesDirectory() -> string { return "/usr/local/share/eagle0/"; }
auto FilesystemUtils::StaticShardokFilesDirectory() -> string {
const char* resourcesPath = getenv("SHARDOK_RESOURCES_PATH");
if (resourcesPath != nullptr) { return string(resourcesPath) + "/"; }
return rLocation + "/src/main/resources/net/eagle0/shardok/";
}
auto FilesystemUtils::MapFilesDirectory() -> string {
const char* mapsPath = getenv("SHARDOK_MAPS_PATH");
if (mapsPath != nullptr) { return string(mapsPath) + "/"; }
return StaticShardokFilesDirectory() + "maps/";
}
@@ -7,7 +7,6 @@
#include <algorithm>
#include <bit>
#include <cstdint>
#include <cstdlib>
#define ITERABLE_BITSET_INDEX_CHECKS false
@@ -9,7 +9,6 @@
#include "MapUtils.hpp"
#include <algorithm>
#include <stdexcept>
static inline std::string StringForKey(
const std::unordered_map<std::string, std::string>& map,
@@ -14,14 +14,6 @@
#include "src/main/cpp/net/eagle0/common/RandomGenerator.hpp"
// A deterministic random generator that returns values from a fixed sequence.
// Used for testing and MCTS simulation where we want specific, predictable outcomes.
//
// Values in the sequence are treated as [0, 1] probabilities that are returned
// by DoubleZeroToOne(). The normal percentile methods (including open-ended
// variants) work as usual, so callers must provide appropriate sequences.
// For example, to get an open-ended low result of -50, provide [0.02, 0.52]
// which produces: initial=2 (triggers open-ended), accumulated=52, final=2-52=-50
class SequenceRandomGenerator : public ::RandomGenerator {
private:
const std::vector<double> sequence;
@@ -9,7 +9,6 @@
#ifndef byte_vector_h
#define byte_vector_h
#include <cstdint>
#include <cstring>
#include <fstream>
#include <sstream>
@@ -5,18 +5,12 @@
#include "AbstractMCTSAI.hpp"
#include <algorithm>
#include <chrono>
#include <fstream>
#include <future>
#include <iomanip>
#include <limits>
#include <mutex>
#include <random>
#include <stdexcept>
#include <thread>
#include "src/main/cpp/net/eagle0/common/mcts/util/TreeIndentUtil.hpp"
namespace shardok::mcts {
AbstractMCTSAI::AbstractMCTSAI(MCTSPlayerId playerId, MCTSConfig config)
@@ -85,9 +79,6 @@ auto AbstractMCTSAI::Search(
LogSearchResults(rootNode.get(), bestChild, result);
}
// Dump tree if explicitly requested via config
if (!config_.debugDumpPath.empty()) { DumpTreeToFile(rootNode.get(), config_.debugDumpPath); }
return result;
}
@@ -95,19 +86,12 @@ auto AbstractMCTSAI::BuildMCTSTree(
const MCTSGameEngine& engine,
const MCTSGameState& initialState,
const std::chrono::steady_clock::time_point deadline) const -> std::unique_ptr<MCTSNode> {
// Clear transposition table for this search
// Maps state hash -> minimum depth, used to detect redundant longer paths
transpositionTable_.clear();
// Create root node
// IMPORTANT: Use the initial state's current player, not playerId_
// node->playerId represents "whose turn it is", not "who we're searching for"
// This is critical for correct player flip tracking
auto root = std::make_unique<MCTSNode>(initialState.clone(), initialState.currentPlayerId(), 0);
// Record root state in transposition table
transpositionTable_[root->stateHash] = root->depth;
// Set whether root is maximizing based on whether current player matches who we're searching
// for
root->isMaximizingPlayer = (initialState.currentPlayerId() == playerId_);
@@ -130,37 +114,6 @@ auto AbstractMCTSAI::BuildMCTSTree(
// Initialize action counter
root->totalActions = rootActions.size();
// CRITICAL: Do at least one expansion before entering the time-bounded loop.
// This ensures we always have at least one child to return, even if the deadline
// has already passed (e.g., due to debugger pause, system load, etc.)
{
auto* selected = MCTSSelection(root.get());
const bool selectedIsRoot = (selected == root.get());
const size_t childrenBeforeExpansion = root->children.size();
if (selected) {
auto* expanded = MCTSExpansion(selected, engine);
const double reward =
MCTSSimulation(engine, *expanded->gameState, playerId_, expanded->playerFlips);
MCTSBackpropagation(expanded, reward, config_.backpropagationPolicy);
}
// Verify we actually have at least one child after the initial expansion
if (root->children.empty()) {
throw MCTSInternalError(
"MCTS BuildMCTSTree: Initial expansion failed to produce any children. "
"totalActions=" +
std::to_string(root->totalActions) +
", selected=" + (selected ? "non-null" : "null") +
", selectedIsRoot=" + (selectedIsRoot ? "true" : "false") +
", childrenBefore=" + std::to_string(childrenBeforeExpansion) +
", childrenAfter=" + std::to_string(root->children.size()) +
", root->CanExpand()=" + (root->CanExpand() ? "true" : "false") +
", root->nextUntriedActionIndex=" +
std::to_string(root->nextUntriedActionIndex));
}
}
std::atomic<int> iterations{0};
if (config_.useMultithreading && config_.numThreads > 1) {
@@ -207,7 +160,7 @@ auto AbstractMCTSAI::BuildMCTSTree(
while (std::chrono::steady_clock::now() < deadline) {
// Selection
auto* selected = MCTSSelection(root.get());
if (!selected) { break; }
if (!selected) break;
// Expansion
auto* expanded = MCTSExpansion(selected, engine);
@@ -237,27 +190,11 @@ auto AbstractMCTSAI::BuildMCTSTree(
auto AbstractMCTSAI::MCTSSelection(MCTSNode* root) const -> MCTSNode* {
MCTSNode* current = root;
while (current->depth < config_.maxTreeDepth) {
// Check expansion FIRST - allows expanding "terminal" nodes that still have
// untried actions (e.g., final round where we need to pick an action)
while (!current->isTerminal && current->depth < config_.maxTreeDepth) {
if (current->CanExpand()) {
return current; // Node has untried actions/outcomes
}
// Only after expansion check: stop if terminal and fully expanded
if (current->isTerminal) {
break; // Terminal and no more actions to try
}
if (!current->children.empty()) {
// Choose child based on node type
if (current->IsChanceNode()) {
// Chance nodes: select outcome proportional to probability
current = current->GetBestChanceChild();
} else {
// Decision nodes: select using UCB1
current = current->GetBestChild(config_.explorationConstant);
}
return current; // Node has untried actions
} else if (!current->children.empty()) {
current = current->GetBestChild(config_.explorationConstant);
if (!current) break;
} else {
break; // Leaf node
@@ -269,109 +206,10 @@ auto AbstractMCTSAI::MCTSSelection(MCTSNode* root) const -> MCTSNode* {
auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine) const
-> MCTSNode* {
// Only skip if we truly can't expand. Allow expansion even if "terminal" as long as
// there are untried actions (e.g., final round where we need to pick an action).
if (!node->CanExpand()) {
if (!node->CanExpand() || node->isTerminal) {
return node; // Nothing to expand
}
// Handle chance node expansion (expanding outcomes)
if (node->IsChanceNode()) {
// Chance nodes expand their outcome children
// This should have been set up when the chance node was created
if (node->outcomeProbabilities.empty()) {
throw MCTSInternalError(
"Chance node has no outcome probabilities - this indicates a bug");
}
const size_t outcomeIndex = node->nextUntriedActionIndex++;
if (outcomeIndex >= node->outcomeProbabilities.size()) {
throw MCTSInternalError(
"Chance node outcomeIndex >= outcomeProbabilities.size() - bug in expansion");
}
// The chance node's action should be the binary action
if (!node->action) {
throw MCTSInternalError("Chance node has no action - this indicates a bug");
}
// Apply the action with the representative roll for this outcome
// Outcome 0 = success, Outcome 1 = failure
// Use the representative roll for this specific outcome
const double representativeRoll = node->outcomeRolls[outcomeIndex];
auto newState = engine.applyAction(*node->gameState, *node->action, representativeRoll);
if (!newState) {
throw MCTSInternalError(
"MCTS expansion: engine.applyAction() returned nullptr for chance node "
"outcome - this indicates a game engine error");
}
// Determine if player changed
const MCTSPlayerId newPlayerId = newState->currentPlayerId();
const bool playerChanged = (newPlayerId != node->playerId);
// Calculate player flips and maximizing status
const int newPlayerFlips = node->playerFlips + (playerChanged ? 1 : 0);
const bool newIsMaximizing = (newPlayerId == playerId_);
// Create outcome child (decision node)
auto outcomeChild = std::make_unique<MCTSNode>(
node->action->clone(),
std::move(newState),
newPlayerId,
node->depth + 1,
outcomeIndex,
newPlayerFlips,
newIsMaximizing,
node->actionWeight); // Inherit action weight from chance node
// Set up outcome child's actions if not terminal
const bool shouldExpand =
!outcomeChild->isTerminal && node->playerFlips <= config_.maxPlayerFlips;
if (shouldExpand) {
const auto childActions = engine.getLegalActions(
*outcomeChild->gameState,
playerId_,
newPlayerFlips,
config_.maxPlayerFlips);
outcomeChild->totalActions = childActions.size();
}
// Calculate scores
outcomeChild->immediateScore = engine.evaluateState(*outcomeChild->gameState, playerId_);
outcomeChild->lookaheadScore = outcomeChild->immediateScore;
// Set parent and add to children
outcomeChild->parent = node;
node->children.push_back(std::move(outcomeChild));
// Update chance node's immediate score to expected value of expanded outcomes
// This corrects the initial value (which incorrectly used parent state) and ensures
// fair UCB comparison with non-chance actions like END_TURN
{
double expectedImmediate = 0.0;
double totalProbability = 0.0;
for (size_t i = 0; i < node->children.size(); i++) {
const double prob = node->outcomeProbabilities[i];
const double childImmediate = node->children[i]->immediateScore;
expectedImmediate += prob * childImmediate;
totalProbability += prob;
}
// Normalize by total probability of expanded outcomes
if (totalProbability > 0.0) {
node->immediateScore = expectedImmediate / totalProbability;
// CRITICAL: Always update lookaheadScore to the expected value.
// Without this, chance nodes keep their initial lookaheadScore from the parent
// state (before the action), while regular actions use the child state (after).
// This gives chance nodes an unfair initial UCB advantage.
node->lookaheadScore = node->immediateScore;
}
}
return node->children.back().get();
}
// Handle decision node expansion (expanding actions)
// Get next action to expand (sequential order)
const size_t actionIndex = node->nextUntriedActionIndex++;
@@ -395,53 +233,9 @@ auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine)
const auto actionWeights = engine.getActionWeights(nodeActions, *node->gameState);
const auto& action = nodeActions[actionIndex];
const double actionWeight =
actionIndex < actionWeights.size() ? actionWeights[actionIndex] : 1.0;
// Check if this action requires a chance node
if (action->requiresChanceNode()) {
// Create intermediate chance node
auto chanceNode = std::make_unique<MCTSNode>(
action->clone(),
node->gameState->clone(), // Chance node has same state as parent
node->playerId,
node->depth + 1,
actionIndex,
node->playerFlips,
node->isMaximizingPlayer,
actionWeight);
chanceNode->nodeType = NodeType::CHANCE;
// Get outcome information from engine
const auto outcomeInfo = engine.getBinaryOutcomeInfo(*node->gameState, *action);
// Set up outcome metadata (2 outcomes for binary actions)
chanceNode->outcomeProbabilities = outcomeInfo.getProbabilities();
chanceNode->outcomeRolls = outcomeInfo.getRepresentativeRolls();
chanceNode->totalActions = 2; // Binary: success and failure
// Chance node immediate score will be computed as expected value during backpropagation
// For now, initialize to parent's score as a reasonable default
chanceNode->immediateScore = engine.evaluateState(*node->gameState, playerId_);
chanceNode->lookaheadScore = chanceNode->immediateScore;
// Set parent and add to children
chanceNode->parent = node;
node->children.push_back(std::move(chanceNode));
// CRITICAL: Immediately expand the first outcome and return that instead.
// If we returned the chance node itself, MCTSSimulation would run on the parent state
// (since chance nodes have parent's gameState), which is wrong. We need to simulate
// from an actual outcome state.
//
// Note: This recursion is bounded because outcome children are decision nodes,
// not chance nodes, so the recursion goes exactly one level deep.
return MCTSExpansion(node->children.back().get(), engine);
}
// Regular (non-chance) action: create decision node directly
auto newState = engine.applyAction(*node->gameState, *action);
if (!newState) {
throw MCTSInternalError(
@@ -468,37 +262,8 @@ auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine)
newIsMaximizing,
actionWeight); // Pass the action weight for prior-weighted UCB
// Check transposition table: mark as redundant if we've reached this state at a shallower depth
// This prevents MCTS from exploring longer paths to the same game state
// Works best with MINIMAX backpropagation (penalty propagates as min/max)
// Also provides benefit with AVERAGING (penalty pulls average down significantly)
const uint64_t childHash = child->stateHash;
auto it = transpositionTable_.find(childHash);
if (it != transpositionTable_.end()) {
const int previousDepth = it->second;
if (child->depth > previousDepth) {
// Longer path to same state - mark as redundant and heavily penalize
// Use -infinity to be unambiguously worse than any legitimate score
child->isRedundant = true;
child->immediateScore = -std::numeric_limits<double>::infinity();
child->lookaheadScore = -std::numeric_limits<double>::infinity();
} else {
// Found shorter or equal path - update table
transpositionTable_[childHash] = child->depth;
}
} else {
// First time seeing this state - record it
transpositionTable_[childHash] = child->depth;
}
// Set up child's untried actions if not terminal and parent hasn't exceeded player flips
// playerFlips counts how many times the player has CHANGED from root
// We expand children of nodes that are within the maxPlayerFlips limit
// maxPlayerFlips=0: same player can take multiple sequential actions
// maxPlayerFlips=1: can explore opponent's immediate responses
const bool shouldExpand = !child->isTerminal && node->playerFlips <= config_.maxPlayerFlips;
if (shouldExpand) {
// Set up child's untried actions if not terminal
if (!child->isTerminal) {
const auto childActions = engine.getLegalActions(
*child->gameState,
playerId_,
@@ -508,11 +273,8 @@ auto AbstractMCTSAI::MCTSExpansion(MCTSNode* node, const MCTSGameEngine& engine)
}
// Calculate immediate and lookahead scores from root player's perspective
// Skip for redundant nodes (already have penalty scores)
if (!child->isRedundant) {
child->immediateScore = engine.evaluateState(*child->gameState, playerId_);
child->lookaheadScore = child->immediateScore;
}
child->immediateScore = engine.evaluateState(*child->gameState, playerId_);
child->lookaheadScore = child->immediateScore;
// Set parent and add to children
child->parent = node;
@@ -528,22 +290,14 @@ auto AbstractMCTSAI::MCTSSimulation(
const int startingPlayerFlips) const -> double {
if (state.isTerminal()) { return state.score(startingPlayer); }
// If we've already exceeded the simulation horizon, don't simulate - just return immediate
// score This ensures fair comparison: all leaves are evaluated at the same game phase Example:
// maxSimulationFlips=1 means simulate THROUGH opponent's first response (i.e., allow one action
// at playerFlips=1, then stop)
if (startingPlayerFlips > config_.maxSimulationFlips) { return state.score(startingPlayer); }
// Create a mutable copy for simulation
auto currentState = state.clone();
int depth = 0;
int playerFlips = startingPlayerFlips; // Start from the expanded node's flip count
MCTSPlayerId previousPlayer = currentState->currentPlayerId();
// Simulate until we exceed the horizon, hit terminal state, or max depth
// Note: We allow one action AT maxSimulationFlips before stopping
while (!currentState->isTerminal() && depth < config_.maxSimulationDepth &&
playerFlips <= config_.maxSimulationFlips) {
// Simulate until terminal or max depth
while (!currentState->isTerminal() && depth < config_.maxSimulationDepth) {
// Track player changes
const MCTSPlayerId currentPlayer = currentState->currentPlayerId();
if (currentPlayer != previousPlayer) {
@@ -556,7 +310,7 @@ auto AbstractMCTSAI::MCTSSimulation(
*currentState,
playerId_,
playerFlips,
config_.maxSimulationFlips);
config_.maxPlayerFlips);
if (actions.empty()) { break; }
// Determine if current player is maximizing or minimizing
@@ -597,79 +351,28 @@ auto AbstractMCTSAI::MCTSBackpropagation(
node->totalReward += reward;
node->averageReward = node->totalReward / node->visitCount;
// Update lookahead score based on node type and strategy
if (node->IsChanceNode() && !node->children.empty()) {
// Chance nodes: compute expected value (weighted average of outcomes)
// lookaheadScore = sum(probability[i] * childValue[i])
double expectedValue = 0.0;
double totalProbability = 0.0;
int visitedChildCount = 0;
for (size_t i = 0; i < node->children.size(); i++) {
const auto& child = node->children[i];
if (child->visitCount == 0) continue; // Unvisited outcomes don't contribute
const double probability = node->outcomeProbabilities[i];
const double childValue = child->lookaheadScore;
expectedValue += probability * childValue;
totalProbability += probability;
visitedChildCount++;
}
// Use expected value if we have visited outcomes, else use average
if (visitedChildCount > 0) {
// CRITICAL: Normalize by total probability to get correct expected value
// when not all outcomes have been visited yet
if (totalProbability > 0.0 && totalProbability < 1.0) {
// Normalize to account for unvisited outcomes
// This gives the correct expected value among visited outcomes
expectedValue /= totalProbability;
}
node->lookaheadScore = expectedValue;
} else {
// No outcomes visited yet, fall back to average
if (node->visitCount == 1) {
node->lookaheadScore = reward;
} else {
const double alpha = 1.0 / node->visitCount;
node->lookaheadScore = (1.0 - alpha) * node->lookaheadScore + alpha * reward;
}
}
} else if (useMinimaxBackup && !node->children.empty()) {
// Update lookahead score based on strategy
if (useMinimaxBackup && !node->children.empty()) {
// Minimax backup: use best/worst child value for adversarial games
// The operation (MAX or MIN) depends on whose turn it is at THIS node
// - If this node is root player's turn: root chooses MAX (best for root)
// - If this node is opponent's turn: opponent chooses MIN (best for opponent = worst
// for root)
//
// Note: In setup phase, children can have different isMaximizingPlayer values:
// - PLACE_UNIT keeps same player's turn
// - END_PLAYER_SETUP flips to opponent's turn
// So we must use the PARENT node's isMaximizingPlayer, not the child's.
// This is correct when exploring opponent responses
double minmaxValue = node->isMaximizingPlayer ? -std::numeric_limits<double>::max()
: std::numeric_limits<double>::max();
const bool thisNodeIsRootPlayer = node->isMaximizingPlayer;
double minmaxValue = thisNodeIsRootPlayer ? -std::numeric_limits<double>::max()
: std::numeric_limits<double>::max();
int visitedChildCount = 0;
for (const auto& child : node->children) {
if (child->visitCount == 0) continue; // Unvisited children don't contribute
const double childValue = child->lookaheadScore;
visitedChildCount++;
if (thisNodeIsRootPlayer) {
// Root player chooses: take MAX (best for root)
if (node->isMaximizingPlayer) {
minmaxValue = std::max(minmaxValue, childValue);
} else {
// Opponent chooses: take MIN (best for opponent = worst for root)
minmaxValue = std::min(minmaxValue, childValue);
}
}
// Use minimax value if we found any visited children, else use average
if (visitedChildCount > 0) {
if (minmaxValue != (node->isMaximizingPlayer ? -std::numeric_limits<double>::max()
: std::numeric_limits<double>::max())) {
node->lookaheadScore = minmaxValue;
} else {
// No children visited yet, fall back to average
@@ -877,17 +580,12 @@ auto AbstractMCTSAI::LogSearchResults(
return a->visitCount > b->visitCount;
});
// Show all actions if there are <= 10, otherwise top 5
const size_t numToShow = sortedChildren.size() <= 10 ? sortedChildren.size() : 5;
printf("MCTS: Top %zu actions by visits (out of %zu total):\n",
numToShow,
sortedChildren.size());
for (size_t i = 0; i < numToShow; ++i) {
printf("MCTS: Top actions by visits:\n");
for (size_t i = 0; i < std::min(static_cast<size_t>(3), sortedChildren.size()); ++i) {
const auto* child = sortedChildren[i];
printf(" [%zu] visits:%d avgReward:%.2f immediate:%.2f lookahead:%.2f",
printf(" [%zu] visits:%d immediate:%.2f lookahead:%.2f",
i,
child->visitCount,
child->averageReward,
child->immediateScore,
child->lookaheadScore);
@@ -941,43 +639,18 @@ auto AbstractMCTSAI::LogSearchResults(
if (!bestSequence.empty()) {
printf("MCTS: Best sequence from chosen action (final: %.2f):\n", sequenceScore);
int displayedStep = 0;
for (size_t i = 0; i < bestSequence.size(); ++i) {
const auto* node = bestSequence[i];
// Skip outcome nodes (children of chance nodes) - they're displayed with their parent
if (i > 0 && node->parent && node->parent->IsChanceNode()) { continue; }
displayedStep++;
printf(" %d.", displayedStep);
printf(" %zu.", i + 1);
if (node->action) { printf(" %s", node->action->getDescription().c_str()); }
// If this is a chance node, display outcome probabilities and scores
if (node->IsChanceNode() && !node->outcomeProbabilities.empty()) {
printf(" [");
for (size_t j = 0; j < node->outcomeProbabilities.size(); ++j) {
if (j > 0) printf(", ");
const double prob = node->outcomeProbabilities[j] * 100;
// Show lookahead score for each outcome if child exists
if (j < node->children.size() && node->children[j]->visitCount > 0) {
printf("%.0f%%->%.1f", prob, node->children[j]->lookaheadScore);
} else {
printf("%.0f%%->?", prob);
}
}
printf("]");
}
printf(" (visits:%d, immediate:%.2f, lookahead:%.2f)\n",
node->visitCount,
node->immediateScore,
node->lookaheadScore);
// For non-root nodes in the sequence, show what the top alternatives were
// Skip showing alternatives for chance nodes (they have outcome children, not action
// alternatives)
if (i > 0 && node->parent && !node->parent->children.empty() &&
!node->parent->IsChanceNode()) {
// This helps diagnose if opponent moves are being properly explored
if (i > 0 && node->parent && !node->parent->children.empty()) {
// Collect all siblings (including this node) and sort by visit count
std::vector<const MCTSNode*> siblings;
siblings.reserve(node->parent->children.size());
@@ -1008,100 +681,4 @@ auto AbstractMCTSAI::LogSearchResults(
}
}
auto AbstractMCTSAI::DumpTreeToFile(const MCTSNode* root, const std::string& filepath) -> void {
if (!root) return;
std::ofstream out(filepath);
if (!out) {
fprintf(stderr, "Failed to open dump file: %s\n", filepath.c_str());
return;
}
out << "MCTS Tree Dump\n";
out << "==============\n\n";
out << "Root Node:\n";
out << " Visits: " << root->visitCount << "\n";
out << " Immediate Score: " << root->immediateScore << "\n";
out << " Lookahead Score: " << root->lookaheadScore << "\n";
out << " Average Reward: " << root->averageReward << "\n";
out << " Player ID: " << root->playerId << "\n";
out << " Depth: " << root->depth << "\n";
out << " Is Maximizing: " << (root->isMaximizingPlayer ? "true" : "false") << "\n";
out << " State Hash: " << std::hex << root->stateHash << std::dec << "\n";
out << "\n";
if (!root->children.empty()) {
out << "Children:\n";
for (size_t i = 0; i < root->children.size(); ++i) {
const auto& child = root->children[i];
const bool isLast = (i == root->children.size() - 1);
DumpNodeRecursive(child.get(), out, 1, isLast);
}
}
out << "\n=== End of Tree Dump ===\n";
out.close();
printf("MCTS: Tree dumped to %s\n", filepath.c_str());
}
auto AbstractMCTSAI::DumpNodeRecursive(
const MCTSNode* node,
std::ostream& out,
const int indentLevel,
const bool isLastChild) -> void {
if (!node) return;
// Create indent string
const std::string indent = ::mcts::util::BuildTreeIndent(indentLevel, isLastChild);
// Write node information
out << indent;
// Show node type for chance nodes
if (node->IsChanceNode()) { out << "[CHANCE] "; }
if (node->action) {
out << node->action->getDescription();
} else {
out << "[ROOT]";
}
out << " (visits:" << node->visitCount;
out << ", immediate:" << std::fixed << std::setprecision(2) << node->immediateScore;
out << ", lookahead:" << node->lookaheadScore;
out << ", avgReward:" << node->averageReward;
out << ", weight:" << node->actionWeight;
out << ", depth:" << node->depth;
out << ", flips:" << node->playerFlips;
out << ", player:" << node->playerId;
out << ", max:" << (node->isMaximizingPlayer ? "T" : "F");
if (node->isRedundant) { out << ", REDUNDANT"; }
if (node->isTerminal) { out << ", TERMINAL"; }
out << ")\n";
// Show outcome probabilities and rolls for chance nodes
if (node->IsChanceNode() && !node->outcomeProbabilities.empty()) {
const std::string outcomeIndent = ::mcts::util::ConvertBranchToContinuation(indent);
out << outcomeIndent << " Outcomes: ";
for (size_t i = 0; i < node->outcomeProbabilities.size(); ++i) {
if (i > 0) out << ", ";
out << "[" << i << "] p=" << std::fixed << std::setprecision(3)
<< node->outcomeProbabilities[i];
if (i < node->outcomeRolls.size()) {
out << " roll=" << std::fixed << std::setprecision(1) << node->outcomeRolls[i];
}
}
out << "\n";
}
// Recursively dump children
if (!node->children.empty()) {
for (size_t i = 0; i < node->children.size(); ++i) {
const auto& child = node->children[i];
const bool isLast = (i == node->children.size() - 1);
DumpNodeRecursive(child.get(), out, indentLevel + 1, isLast);
}
}
}
} // namespace shardok::mcts
@@ -7,7 +7,6 @@
#include <chrono>
#include <memory>
#include <unordered_map>
#include <vector>
#include "MCTSAction.hpp"
@@ -52,11 +51,6 @@ private:
MCTSPlayerId playerId_;
MCTSConfig config_;
// Transposition table: maps state hash -> minimum depth at which state was reached
// Used to detect and penalize longer paths to the same game state
// Cleared at the start of each Search() call
mutable std::unordered_map<uint64_t, int> transpositionTable_;
// Core MCTS algorithm
[[nodiscard]] auto BuildMCTSTree(
const MCTSGameEngine& engine,
@@ -90,14 +84,6 @@ private:
const MCTSNode* rootNode,
const MCTSNode* bestChild,
const SearchResult& result) -> void;
// Debug tree dumping
static auto DumpTreeToFile(const MCTSNode* root, const std::string& filepath) -> void;
private:
static auto
DumpNodeRecursive(const MCTSNode* node, std::ostream& out, int indentLevel, bool isLastChild)
-> void;
};
} // namespace mcts
@@ -86,7 +86,6 @@ cc_library(
":mcts_game_state",
":mcts_node",
":mcts_types",
"//src/main/cpp/net/eagle0/common/mcts/util:tree_indent_util",
],
)
@@ -27,11 +27,6 @@ public:
// Check if two actions are equivalent
[[nodiscard]] virtual bool equals(const MCTSAction& other) const = 0;
// Check if this action requires a chance node (binary success/failure outcome)
// Examples: START_FIRE, RAISE_DEAD, EXTINGUISH_FIRE
// If true, the game engine should provide outcome probabilities
[[nodiscard]] virtual bool requiresChanceNode() const = 0;
};
} // namespace mcts
@@ -16,50 +16,15 @@
namespace shardok {
namespace mcts {
// Information about chance outcomes (supports both binary and multi-outcome)
struct ChanceOutcomeInfo {
std::vector<double> probabilities; // Probability of each outcome (must sum to 1.0)
std::vector<double> rolls; // Roll values for each outcome
// Factory for binary success/failure outcomes (e.g., START_FIRE)
[[nodiscard]] static ChanceOutcomeInfo binary(double successProbability) {
// -100: triggers open-ended low sequence, succeeds against any threshold
// 150: triggers open-ended high sequence, fails against any threshold
return {{successProbability, 1.0 - successProbability}, {-100.0, 150.0}};
}
// Factory for multi-outcome with fixed seeds (e.g., END_TURN)
// Uses uniformly distributed roll values to sample different random outcomes
[[nodiscard]] static ChanceOutcomeInfo multiOutcome(int numOutcomes) {
std::vector<double> probs(numOutcomes, 1.0 / numOutcomes);
std::vector<double> rollValues;
rollValues.reserve(numOutcomes);
// Spread rolls across the percentile range: 10, 30, 50, 70, 90 for 5 outcomes
for (int i = 0; i < numOutcomes; ++i) {
rollValues.push_back(10.0 + (80.0 * i) / (numOutcomes - 1));
}
return {probs, rollValues};
}
[[nodiscard]] const std::vector<double>& getRepresentativeRolls() const { return rolls; }
[[nodiscard]] const std::vector<double>& getProbabilities() const { return probabilities; }
};
// Backward compatibility alias
using BinaryOutcomeInfo = ChanceOutcomeInfo;
// Abstract interface for game engines
class MCTSGameEngine {
public:
virtual ~MCTSGameEngine() = default;
// Apply an action to a state and return the resulting state
// If deterministicRoll is provided (0.0-100.0), use that for any random outcomes
[[nodiscard]] virtual std::unique_ptr<MCTSGameState> applyAction(
const MCTSGameState& state,
const MCTSAction& action,
double deterministicRoll = -1.0) const = 0;
const MCTSAction& action) const = 0;
// Apply an action to a mutable state in-place (for efficient simulation)
// Default: clone, apply, and move the result back
@@ -146,13 +111,6 @@ public:
(void)state; // Suppress unused parameter warning
return filteredIndex;
}
// Get binary outcome information for an action that requires a chance node
// Only called for actions where action.requiresChanceNode() returns true
// Returns success probability for binary success/failure actions
[[nodiscard]] virtual BinaryOutcomeInfo getBinaryOutcomeInfo(
const MCTSGameState& state,
const MCTSAction& action) const = 0;
};
} // namespace mcts
@@ -17,16 +17,8 @@
namespace shardok {
namespace mcts {
// Node type for MCTS tree
enum class NodeType {
DECISION, // Player chooses an action (standard MCTS node)
CHANCE // Nature determines outcome (for probabilistic actions)
};
// Abstract MCTS Node structure
struct MCTSNode {
// Node type
NodeType nodeType = NodeType::DECISION;
// Action information
std::unique_ptr<MCTSAction> action; // The action that led to this node (null for root)
size_t actionIndex = SIZE_MAX; // Index in the original actions array (SIZE_MAX for root)
@@ -51,10 +43,6 @@ struct MCTSNode {
size_t totalActions = 0; // Total number of available actions
MCTSNode* parent = nullptr;
// Chance node specific fields (only used when nodeType == CHANCE)
std::vector<double> outcomeProbabilities; // Probability of each outcome
std::vector<double> outcomeRolls; // Representative roll for each outcome
// Game context
MCTSPlayerId playerId;
int depth = 0;
@@ -148,40 +136,6 @@ struct MCTSNode {
// Check if this node can be expanded
[[nodiscard]] bool CanExpand() const { return nextUntriedActionIndex < totalActions; }
// Check if this is a chance node
[[nodiscard]] bool IsChanceNode() const { return nodeType == NodeType::CHANCE; }
// Check if this is a decision node
[[nodiscard]] bool IsDecisionNode() const { return nodeType == NodeType::DECISION; }
// Get best child from chance node (probability-weighted selection)
// For chance nodes, we want to explore outcomes proportionally to their probability
[[nodiscard]] MCTSNode* GetBestChanceChild() const {
if (children.empty() || !IsChanceNode()) return nullptr;
// Find the outcome that is most under-explored relative to its probability
// Expected visits for outcome i: total_visits * probability[i]
// Actual visits: child[i]->visitCount
// Deficit: expected - actual
size_t bestIndex = 0;
double bestDeficit = -std::numeric_limits<double>::max();
for (size_t i = 0; i < children.size(); i++) {
if (!children[i] || children[i]->isRedundant) continue;
const double expectedVisits = visitCount * outcomeProbabilities[i];
const double actualVisits = static_cast<double>(children[i]->visitCount);
const double deficit = expectedVisits - actualVisits;
if (deficit > bestDeficit) {
bestDeficit = deficit;
bestIndex = i;
}
}
return children[bestIndex].get();
}
// Get best child based on UCB1
[[nodiscard]] MCTSNode* GetBestChild(const double explorationConstant) const {
if (children.empty()) return nullptr;
@@ -44,14 +44,8 @@ struct MCTSConfig {
int numThreads = 16; // Number of threads for parallel MCTS
MCTSSimulationPolicy simulationPolicy = MCTSSimulationPolicy::BEST_IMMEDIATE;
MCTSBackpropagationPolicy backpropagationPolicy = MCTSBackpropagationPolicy::AVERAGING;
int maxPlayerFlips = 0; // Maximum number of player changes for tree expansion
// (0 = expand through current player's turn only,
// 1 = expand through opponent's first response, etc.)
int maxSimulationFlips = 0; // Maximum player flips for leaf evaluation
// When evaluating a leaf at playerFlips < maxSimulationFlips,
// simulate forward to this phase for fair comparison
// (default 0 = evaluate leaves as-is, backward compatible)
std::string debugDumpPath = ""; // If non-empty, dump MCTS tree to this file path
int maxPlayerFlips = 0; // Maximum number of player changes to explore (0 = stop at first
// flip, 1 = explore through opponent's response, etc.)
};
} // namespace mcts
@@ -1,8 +0,0 @@
load("@rules_cc//cc:defs.bzl", "cc_library")
cc_library(
name = "tree_indent_util",
srcs = ["TreeIndentUtil.cpp"],
hdrs = ["TreeIndentUtil.hpp"],
visibility = ["//visibility:public"],
)
@@ -1,53 +0,0 @@
//
// Utility functions for processing tree indentation with UTF-8 box drawing characters
//
#include "TreeIndentUtil.hpp"
namespace mcts::util {
namespace {
// Box drawing characters for tree visualization
constexpr const char* kBranch = "\xE2\x94\x9C"; // ├
constexpr const char* kCorner = "\xE2\x94\x94"; // └
constexpr const char* kVertical = "\xE2\x94\x82"; // │
constexpr const char* kHorizontal = "\xE2\x94\x80"; // ─
} // namespace
std::string BuildTreeIndent(int indentLevel, bool isLastChild) {
std::string indent;
for (int i = 0; i < indentLevel; ++i) {
if (i == indentLevel - 1) {
indent += isLastChild ? kCorner : kBranch;
indent += kHorizontal;
indent += " ";
} else {
indent += " ";
}
}
return indent;
}
std::string ConvertBranchToContinuation(const std::string& indent) {
std::string result = indent;
const std::string replacement = std::string(kVertical) + " ";
// Replace ├ and └ with │
size_t pos = 0;
while ((pos = result.find(kBranch, pos)) != std::string::npos) {
result.replace(pos, 3, replacement); // UTF-8 chars are 3 bytes
pos += replacement.size();
}
pos = 0;
while ((pos = result.find(kCorner, pos)) != std::string::npos) {
result.replace(pos, 3, replacement);
pos += replacement.size();
}
return result;
}
} // namespace mcts::util
@@ -1,22 +0,0 @@
//
// Utility functions for processing tree indentation with UTF-8 box drawing characters
//
#ifndef EAGLE0_TREE_INDENT_UTIL_HPP
#define EAGLE0_TREE_INDENT_UTIL_HPP
#include <string>
namespace mcts::util {
// Builds tree indentation string for a node at a given depth
// Returns string like " ├─ " or " └─ " with proper spacing
std::string BuildTreeIndent(int indentLevel, bool isLastChild);
// Converts tree branch characters (├ and └) to continuation lines (│) for sub-content
// This preserves the tree structure when displaying additional info below a node
std::string ConvertBranchToContinuation(const std::string& indent);
} // namespace mcts::util
#endif // EAGLE0_TREE_INDENT_UTIL_HPP
@@ -27,7 +27,7 @@ auto AIAttackerStrategySelector::BestAttackerStrategy(
const BattalionTypeGetter& battalionTypeGetter,
ActionPoints braveWaterCost,
const AIWaterCrossingCommandChooser& waterCrossingCommandChooser,
const CommandListSPtr& /*availableCommands*/) -> AIStrategy {
const vector<CommandProto>& /*availableCommands*/) -> AIStrategy {
uint32_t attackerUnitCount = 0;
int defenderOccupiedCriticalTileCount = 0;
bool canFlee = false;
@@ -10,7 +10,6 @@
#include "src/main/cpp/net/eagle0/shardok/ai/AIStrategy.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIWaterCrossingCommandChooser.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCommand.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
@@ -28,7 +27,7 @@ public:
const BattalionTypeGetter& battalionTypeGetter,
ActionPoints braveWaterCost,
const AIWaterCrossingCommandChooser& waterCrossingCommandChooser,
const CommandListSPtr& availableCommands) -> AIStrategy;
const vector<CommandProto>& availableCommands) -> AIStrategy;
};
} // namespace shardok
@@ -14,6 +14,7 @@
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
namespace shardok {
@@ -23,6 +24,7 @@ class AIScoreCalculator;
class ShardokEngine;
using ScoreValue = double;
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
using CommandType = net::eagle0::shardok::common::CommandType;
using BattalionTypeGetter = std::function<BattalionTypeSPtr(BattalionTypeId)>;
@@ -7,7 +7,6 @@
#include <algorithm>
#include "src/main/cpp/net/eagle0/shardok/library/BattalionType.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokException.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexCubeUtils.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
@@ -144,16 +143,15 @@ bool AICommandFilter::IsWastefulAction(
if (!isDefender) {
// Attackers: Only allow fire if the target location is on or adjacent to an enemy
const int targetRow = cmd.GetTargetRow();
const int targetCol = cmd.GetTargetColumn();
if (targetRow < 0 || targetCol < 0) {
throw ShardokInternalErrorException(
"START_FIRE_COMMAND missing required target information");
const auto cmdProto = cmd.GetCommandProto();
if (!cmdProto.has_target()) {
return true; // Can't analyze without target info
}
const Coords fireLocation(
static_cast<int8_t>(targetRow),
static_cast<int8_t>(targetCol));
const auto& targetCoords = cmdProto.target();
const Coords fireLocation{
static_cast<int8_t>(targetCoords.row()),
static_cast<int8_t>(targetCoords.column())};
// Check if any enemy is on the fire location or adjacent to it
bool enemyNearFireLocation = false;
@@ -188,12 +186,13 @@ bool AICommandFilter::IsWastefulAction(
if (!isDefender) {
// Attackers: Only allow fortify if within 3 hexes of enemies or castles
const int unitId = cmd.GetActorUnitId();
if (unitId < 0) {
throw ShardokInternalErrorException(
"FORTIFY_COMMAND missing required actor information");
const auto cmdProto = cmd.GetCommandProto();
if (!cmdProto.has_actor()) {
return true; // Can't analyze without actor info
}
const auto unitId = cmdProto.actor().value();
// Get the acting unit directly by ID
const Unit* actingUnit = gameState->units()->Get(unitId);
// verify the unit is still active
@@ -250,18 +249,16 @@ bool AICommandFilter::IsWastefulAction(
// These actions can fail, so we need high confidence of benefit (8+ action points
// saved)
const int unitId = cmd.GetActorUnitId();
const int targetRow = cmd.GetTargetRow();
const int targetCol = cmd.GetTargetColumn();
if (unitId < 0 || targetRow < 0 || targetCol < 0) {
throw ShardokInternalErrorException(
"BUILD_BRIDGE/FREEZE_WATER_COMMAND missing required actor or target "
"information");
const auto cmdProto = cmd.GetCommandProto();
if (!cmdProto.has_actor() || !cmdProto.has_target()) {
return true; // Can't analyze without full command info
}
const Coords waterLocation(
static_cast<int8_t>(targetRow),
static_cast<int8_t>(targetCol));
const auto unitId = cmdProto.actor().value();
const auto& targetCoords = cmdProto.target();
const Coords waterLocation{
static_cast<int8_t>(targetCoords.row()),
static_cast<int8_t>(targetCoords.column())};
// Get the acting unit directly by ID
const Unit* actingUnit = gameState->units()->Get(unitId);
@@ -356,16 +353,15 @@ bool AICommandFilter::IsWastefulAction(
case CommandType::REPAIR_COMMAND: {
// Repair filtering - filter repairs with high integrity targets
// Note: RepairCommandFactory already filters enemy-occupied targets
const int targetRow = cmd.GetTargetRow();
const int targetCol = cmd.GetTargetColumn();
if (targetRow < 0 || targetCol < 0) {
throw ShardokInternalErrorException(
"REPAIR_COMMAND missing required target information");
const auto cmdProto = cmd.GetCommandProto();
if (!cmdProto.has_target()) {
return true; // Can't analyze without target info
}
const Coords repairLocation(
static_cast<int8_t>(targetRow),
static_cast<int8_t>(targetCol));
const auto& targetCoords = cmdProto.target();
const Coords repairLocation{
static_cast<int8_t>(targetCoords.row()),
static_cast<int8_t>(targetCoords.column())};
// Check terrain modifiers at target location
const auto* terrain = GetTerrain(gameState->hex_map(), repairLocation);
@@ -388,16 +384,15 @@ bool AICommandFilter::IsWastefulAction(
case CommandType::EXTINGUISH_FIRE_COMMAND: {
// Extinguish fire filtering - don't extinguish fires on enemy-occupied tiles
const int targetRow = cmd.GetTargetRow();
const int targetCol = cmd.GetTargetColumn();
if (targetRow < 0 || targetCol < 0) {
throw ShardokInternalErrorException(
"EXTINGUISH_FIRE_COMMAND missing required target information");
const auto cmdProto = cmd.GetCommandProto();
if (!cmdProto.has_target()) {
return true; // Can't analyze without target info
}
const Coords fireLocation(
static_cast<int8_t>(targetRow),
static_cast<int8_t>(targetCol));
const auto& targetCoords = cmdProto.target();
const Coords fireLocation{
static_cast<int8_t>(targetCoords.row()),
static_cast<int8_t>(targetCoords.column())};
// Check if any enemy occupies the fire location - let them burn!
std::vector<PlayerId> allyPids; // Empty for now - assume 2-player game
@@ -429,17 +424,17 @@ bool AICommandFilter::IsWastefulMovement(
return false; // Don't filter defender movement or when close to enemies
}
// Get unit and target information directly from command
const int unitId = cmd.GetActorUnitId();
const int targetRow = cmd.GetTargetRow();
const int targetCol = cmd.GetTargetColumn();
// Get the command proto to access unit and target information
const auto cmdProto = cmd.GetCommandProto();
// Check if we have the required information
if (unitId < 0 || targetRow < 0 || targetCol < 0) {
throw ShardokInternalErrorException(
"MOVE_COMMAND missing required actor or target information");
if (!cmdProto.has_actor() || !cmdProto.has_target()) {
return false; // Can't analyze without unit and target info
}
const auto unitId = cmdProto.actor().value();
const auto& targetCoords = cmdProto.target();
// Get the acting unit directly by ID
const Unit* actingUnit = gameState->units()->Get(unitId);
// Verify the unit is still active
@@ -455,7 +450,9 @@ bool AICommandFilter::IsWastefulMovement(
}
const auto& currentCoords = actingUnit->location();
const Coords targetCoordsFlat(static_cast<int8_t>(targetRow), static_cast<int8_t>(targetCol));
const Coords targetCoordsFlat{
static_cast<int8_t>(targetCoords.row()),
static_cast<int8_t>(targetCoords.column())};
// Get action point distances for this unit's battalion type
const auto& battType = battalionTypeGetter(actingUnit->battalion().type());
@@ -14,6 +14,7 @@
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
namespace shardok {
@@ -15,9 +15,9 @@
namespace shardok {
auto AIFleeDecisionCalculator::GetFleeCommandIndex(
const CommandList::const_iterator& fleeCommand,
const CommandListSPtr& availableCommands) -> size_t {
return static_cast<size_t>(std::distance(availableCommands->begin(), fleeCommand));
const vector<CommandProto>::const_iterator& fleeCommand,
const vector<CommandProto>& availableCommands) -> size_t {
return static_cast<size_t>(std::distance(availableCommands.begin(), fleeCommand));
}
auto AIFleeDecisionCalculator::EstimateCombatSuccess(
@@ -134,14 +134,14 @@ auto AIFleeDecisionCalculator::EstimateCombatSuccess(
auto AIFleeDecisionCalculator::EvaluateFleeVsFight(
PlayerId playerId,
const GameStateW& guessedState,
const CommandListSPtr& availableCommands,
const CommandList::const_iterator& fleeCommand,
const vector<CommandProto>& availableCommands,
const vector<CommandProto>::const_iterator& fleeCommand,
int maxRounds,
int minimumFleeOddsThreshold,
int desperateFleeThreshold,
bool enableDebugLogging) -> FleeDecision {
// Get flee success odds
const int fleeSuccessChance = (*fleeCommand)->GetOddsPercentile();
const int fleeSuccessChance = fleeCommand->odds().success_chance();
if (enableDebugLogging) {
printf("AI FinalRound: Evaluating flee (odds=%d%%)...\n", fleeSuccessChance);
@@ -9,11 +9,13 @@
#ifndef AIFleeDecisionCalculator_hpp
#define AIFleeDecisionCalculator_hpp
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCommand.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
namespace shardok {
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
class AIFleeDecisionCalculator {
public:
// Configuration for flee decision thresholds
@@ -33,8 +35,8 @@ public:
[[nodiscard]] static auto EvaluateFleeVsFight(
PlayerId playerId,
const GameStateW& guessedState,
const CommandListSPtr& availableCommands,
const CommandList::const_iterator& fleeCommand,
const vector<CommandProto>& availableCommands,
const vector<CommandProto>::const_iterator& fleeCommand,
int maxRounds,
int minimumFleeOddsThreshold,
int desperateFleeThreshold,
@@ -57,8 +59,8 @@ public:
private:
// Helper to get flee command index
[[nodiscard]] static auto GetFleeCommandIndex(
const CommandList::const_iterator& fleeCommand,
const CommandListSPtr& availableCommands) -> size_t;
const vector<CommandProto>::const_iterator& fleeCommand,
const vector<CommandProto>& availableCommands) -> size_t;
};
} // namespace shardok
@@ -4,7 +4,6 @@
#include "AIHeuristicWeighting.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokException.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistances.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
@@ -15,10 +14,7 @@ using Coords = net::eagle0::shardok::storage::fb::Coords;
using ProtoCoords = net::eagle0::shardok::common::Coords;
double AIHeuristicWeighting::GetCommandWeight(
const CommandType commandType,
const UnitId actorUnitId,
const PlayerId actorPlayerId,
const Coords& targetCoords,
const net::eagle0::shardok::api::CommandDescriptor& command,
const GameStateW& state,
const CoordsSet& castleCoords,
const APDCache* apdCache,
@@ -30,9 +26,9 @@ double AIHeuristicWeighting::GetCommandWeight(
const auto* hexMap = state->hex_map();
const auto* units = state->units();
const bool hasTarget = (targetCoords.row() >= 0 && targetCoords.column() >= 0);
const auto actorPlayerId = command.player();
switch (commandType) {
switch (command.type()) {
// === HIGH VALUE OFFENSIVE (10.0) ===
// Ranged attacks - very valuable, typically available when in range
case CommandType::ARCHERY_COMMAND: return 20.0;
@@ -41,27 +37,20 @@ double AIHeuristicWeighting::GetCommandWeight(
// Area/tactical spells - high impact
case CommandType::METEOR_START_COMMAND: {
// METEOR_START doesn't have a target - it's based on actor location
if (hasTarget) {
throw ShardokInternalErrorException(
"METEOR_START_COMMAND should not have target coordinates");
}
// High weight per enemy unit at or adjacent to target
if (!command.has_target()) return 0.0; // Default if no target info
// Get actor's location
const auto* actorUnit = units->Get(actorUnitId);
if (!actorUnit) {
throw ShardokInternalErrorException(
"METEOR_START_COMMAND actor unit not found in game state");
}
const Coords& actorLocation = actorUnit->location();
const Coords targetCoords(command.target().row(), command.target().column());
int enemyCount = 0;
// Count enemies within meteor range (3 hexes) of actor location
constexpr int METEOR_RANGE = 3;
const auto tilesInRange = TilesWithinDistance(hexMap, actorLocation, METEOR_RANGE);
for (const auto& tileCoords : tilesInRange) {
if (const auto* unit = Occupant(units, tileCoords)) {
// Count enemies at target
if (const auto* targetUnit = Occupant(units, targetCoords)) {
if (targetUnit->player_id() != actorPlayerId) { enemyCount++; }
}
// Count enemies adjacent to target
for (const auto& neighbor : HexMapUtils::GetAdjacentTiles(hexMap, targetCoords)) {
if (const auto* unit = Occupant(units, neighbor.coords)) {
if (unit->player_id() != actorPlayerId) { enemyCount++; }
}
}
@@ -71,12 +60,9 @@ double AIHeuristicWeighting::GetCommandWeight(
case CommandType::METEOR_TARGET_COMMAND: {
// High weight per enemy unit at or adjacent to target
if (!hasTarget) {
throw ShardokInternalErrorException(
"METEOR_TARGET_COMMAND requires target coordinates for heuristic "
"weighting");
}
if (!command.has_target()) return 6.0; // Default if no target info
const Coords targetCoords(command.target().row(), command.target().column());
int enemyCount = 0;
// Count enemies at target
@@ -100,17 +86,15 @@ double AIHeuristicWeighting::GetCommandWeight(
// Fire on enemy (context-dependent)
case CommandType::START_FIRE_COMMAND: {
// High if enemy at target, low otherwise
if (!hasTarget) {
throw ShardokInternalErrorException(
"START_FIRE_COMMAND requires target coordinates for heuristic weighting");
}
if (!command.has_target()) return 3.0; // Default
const Coords targetCoords(command.target().row(), command.target().column());
if (const auto* targetUnit = Occupant(units, targetCoords)) {
if (targetUnit->player_id() != actorPlayerId) {
return 10.0; // Enemy at target - high value
}
}
return 1.0; // No enemy - low value but still valid
return 1.0; // No enemy - low value
}
// === MEDIUM-HIGH OFFENSIVE (5.0-7.0) ===
@@ -125,8 +109,9 @@ double AIHeuristicWeighting::GetCommandWeight(
case CommandType::REDUCE_COMMAND: {
// High if enemy at target, zero otherwise
if (!hasTarget) return 0.0;
if (!command.has_target()) return 0.0;
const Coords targetCoords(command.target().row(), command.target().column());
if (const auto* targetUnit = Occupant(units, targetCoords)) {
if (targetUnit->player_id() != actorPlayerId) {
return 10.0; // Enemy at target - very high value
@@ -142,13 +127,10 @@ double AIHeuristicWeighting::GetCommandWeight(
}
// Attackers: weight based on distance improvement towards castle
if (!hasTarget) {
throw ShardokInternalErrorException(
"MOVE_COMMAND requires target coordinates for heuristic weighting");
}
if (!command.has_target()) return 4.0; // Default if no target
// Get actor unit to determine battalion type and start position
const auto* actorUnit = units->Get(actorUnitId);
const auto* actorUnit = units->Get(command.actor().value());
if (!actorUnit) return 4.0; // Default if can't find actor
// Get battalion type for distance calculation
@@ -170,7 +152,7 @@ double AIHeuristicWeighting::GetCommandWeight(
}
// Calculate minimum distance from end to any castle
const Coords& endCoords = targetCoords;
const Coords endCoords(command.target().row(), command.target().column());
auto minEndDistance = ActionPointDistances::IMPOSSIBLE;
for (const auto& castleCoord : castleCoords) {
const auto dist = apd->Distance(endCoords, castleCoord);
@@ -199,18 +181,15 @@ double AIHeuristicWeighting::GetCommandWeight(
// === LOW VALUE DEFENSIVE/UTILITY (1.0-2.0) ===
case CommandType::EXTINGUISH_FIRE_COMMAND: {
// High if friendly at target, low otherwise
if (!hasTarget) {
throw ShardokInternalErrorException(
"EXTINGUISH_FIRE_COMMAND requires target coordinates for heuristic "
"weighting");
}
if (!command.has_target()) return 2.0; // Default
const Coords targetCoords(command.target().row(), command.target().column());
if (const auto* targetUnit = Occupant(units, targetCoords)) {
if (targetUnit->player_id() == actorPlayerId) {
return 8.0; // Friendly at target - high value
}
}
return 1.0; // No friendly - low value but still valid
return 1.0; // No friendly - low value
}
case CommandType::UNIT_REST_COMMAND: return 1.5;
@@ -8,6 +8,7 @@
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-redundant-constexpr-static-def"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
#pragma clang diagnostic pop
@@ -24,10 +25,7 @@ public:
// Get weight for a command using fast heuristics with game context
// Returns weight >= 0.0, where 0.0 means "never select" and higher is more likely
static double GetCommandWeight(
net::eagle0::shardok::common::CommandType commandType,
UnitId actorUnitId,
PlayerId actorPlayerId,
const Coords& targetCoords,
const net::eagle0::shardok::api::CommandDescriptor& command,
const GameStateW& state,
const CoordsSet& castleCoords,
const APDCache* apdCache,
@@ -25,8 +25,7 @@ auto CalculateTimeBudget(
const PlayerId playerId,
const GameSettingsSPtr &settings,
const GameStateW &state,
const size_t numCommands,
const bool isAllAiBattle) -> AITimeBudget {
const size_t numCommands) -> AITimeBudget {
const auto settingsGetter = settings->GetGetter();
const auto castleCoords = AllCastleCoords(state->hex_map());
@@ -35,10 +34,8 @@ auto CalculateTimeBudget(
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP;
// Get maximum budget cap from settings (in seconds)
// For all-AI battles, use the faster budget limit
const double maxBudgetSeconds =
isAllAiBattle ? settingsGetter.Backing().all_ai_battle_time_budget_maximum()
: settingsGetter.Backing().lookahead_time_budget_maximum_seconds();
settingsGetter.Backing().lookahead_time_budget_maximum_seconds();
const double maxBudgetMs = maxBudgetSeconds * 1000.0;
// During setup, use the setup-specific time budget
@@ -116,15 +113,6 @@ auto CalculateTimeBudget(
const auto clampedBudgetMs = std::clamp(budgetMs, 200.0, maxBudgetMs);
const auto remainingBudget = std::chrono::milliseconds(static_cast<int64_t>(clampedBudgetMs));
// TEMPORARY DEBUG OUTPUT
printf("[DEBUG CalculateTimeBudget] numCommands=%zu, msPerCommand=%.2f, budgetMs=%.2f, "
"clampedBudgetMs=%.2f, isClose=%d\n",
numCommands,
msPerCommand,
budgetMs,
clampedBudgetMs,
isClose);
// Get minimum depth requirement
const size_t minDepth = settingsGetter.Backing().min_lookahead_turns();
@@ -38,13 +38,11 @@ struct AITimeBudget {
// Calculate time budget based on proximity to enemies and castles
// Time budget is calculated dynamically based on number of available commands:
// budget = msPerCommand × numCommands (clamped to 200-5000ms)
// If isAllAiBattle is true, uses allAiBattleTimeBudgetMaximum instead of the normal maximum.
auto CalculateTimeBudget(
PlayerId playerId,
const GameSettingsSPtr &settings,
const GameStateW &state,
size_t numCommands,
bool isAllAiBattle) -> AITimeBudget;
size_t numCommands) -> AITimeBudget;
} // namespace shardok
@@ -17,10 +17,9 @@ using std::end;
using std::shared_ptr;
constexpr double kProfessionValue = 200;
constexpr double kVigorScoreMultiplier = 5.0;
constexpr double kCastleMultiplierBonus = 1.0;
constexpr double kOnFireMultiplier = 0.25;
constexpr double kAdjacentFireMultiplier = 0.80;
constexpr double kAdjacentFireMultiplier = 0.99;
constexpr double kOnIceMultiplier = 0.25;
constexpr double kMeteorStartInRangeValue = 50;
constexpr double kMeteorDirectTargetingEnemy = 2;
@@ -64,8 +63,7 @@ auto ContextFreeUnitValue(const Unit *unit) -> ScoreValue {
4.0;
}
const double vigorValue =
unit->has_attached_hero() ? unit->attached_hero().vigor() * kVigorScoreMultiplier : 0.0;
const double vigorValue = unit->has_attached_hero() ? unit->attached_hero().vigor() : 0.0;
double battalionTypeMultiplier = 1.0;
switch (unit->battalion().type()) {
@@ -357,7 +355,9 @@ auto UnitValue(
kCastleMultiplierBonus * (terrain->modifier().castle().integrity() + 25) / 100.0;
}
double onFireMultiplier = 1.0;
if (terrain->modifier().fire().present()) { onFireMultiplier *= kOnFireMultiplier; }
if (terrain->modifier().fire().present() && (isAttacker || attackerWantsCastles)) {
onFireMultiplier *= kOnFireMultiplier;
}
{
for (const auto adjacentCoords = HexMapUtils::GetAdjacentCoords(map, location);
const auto &c : adjacentCoords) {
@@ -13,9 +13,11 @@
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/game_state.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
namespace shardok {
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
using GameState = net::eagle0::shardok::storage::fb::GameState;
using Unit = net::eagle0::shardok::storage::fb::Unit;
using ScoreValue = double;
@@ -164,6 +164,7 @@ cc_library(
":ai_unit_score_calculator",
"//src/main/cpp/net/eagle0/shardok/library:engine",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
],
)
@@ -181,6 +182,7 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
"//src/main/cpp/net/eagle0/shardok/library/map:coords_set",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
"//src/main/protobuf/net/eagle0/shardok/common:command_type_cc_proto",
],
)
@@ -204,6 +206,7 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library/map:coords_set",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_cube_utils",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
"//src/main/protobuf/net/eagle0/shardok/common:command_type_cc_proto",
],
)
@@ -226,6 +229,7 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
"//src/main/flatbuffer/net/eagle0/shardok/storage:game_state_cc_fbs",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
"//src/main/protobuf/net/eagle0/shardok/common:command_type_cc_proto",
],
)
@@ -312,6 +316,7 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
],
)
@@ -354,6 +359,7 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
"//src/main/cpp/net/eagle0/shardok/library:engine",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
],
)
@@ -38,7 +38,7 @@ IterativeDeepeningAI::IterativeDeepeningAI(
auto IterativeDeepeningAI::IterativeSearch(
const GameSettingsSPtr& settings,
const GameStateW& state,
const CommandListSPtr& commands,
const std::vector<CommandProto>& commands,
const AITimeBudget& initialBudget) const -> SearchResult {
// Make a mutable copy of the time budget to track remaining time
AITimeBudget timeBudget = initialBudget;
@@ -51,7 +51,7 @@ auto IterativeDeepeningAI::IterativeSearch(
// DEBUG: Clear TT to see if that's causing the suspicious depth reaching
// g_transpositionTable.clear(); // Uncomment to test without cross-search caching
if (commands->empty()) {
if (commands.empty()) {
#if DEBUG_ITERATIVE_DEEPENING_TIMINGS
printf("ID AI: Commands are empty, returning early\n");
#endif
@@ -75,9 +75,9 @@ auto IterativeDeepeningAI::IterativeSearch(
// Initialize data structures for tracking scores at each depth
scoresByDepth.clear();
scoresByDepth.resize(commands->size());
scoresByDepth.resize(commands.size());
highestDepthCompleted.clear();
highestDepthCompleted.resize(commands->size(), 0);
highestDepthCompleted.resize(commands.size(), 0);
size_t currentDepth = 1;
size_t previousBestCommand = 0; // Track best command from previous depth
@@ -132,8 +132,7 @@ auto IterativeDeepeningAI::IterativeSearch(
evaluatedCount++;
// Check if this command is not END_TURN_COMMAND
if ((*commands)[cmdIndex]->GetCommandType() !=
net::eagle0::shardok::common::END_TURN_COMMAND) {
if (commands[cmdIndex].type() != net::eagle0::shardok::common::END_TURN_COMMAND) {
allEndTurnCommands = false;
}
}
@@ -144,7 +143,7 @@ auto IterativeDeepeningAI::IterativeSearch(
size_t currentBestCommand = 0;
ScoreValue currentBestScore = -std::numeric_limits<ScoreValue>::infinity();
for (size_t i = 0; i < commands->size(); ++i) {
for (size_t i = 0; i < commands.size(); ++i) {
if (highestDepthCompleted[i] >= currentDepth) {
if (scoresByDepth[i][currentDepth] > currentBestScore) {
currentBestScore = scoresByDepth[i][currentDepth];
@@ -157,20 +156,16 @@ auto IterativeDeepeningAI::IterativeSearch(
if (currentDepth > 1 && currentBestCommand != previousBestCommand) {
#if DEBUG_ITERATIVE_DEEPENING_TIMINGS
printf("ID AI: Best command changed at depth %lu:\n", currentDepth);
printf(" Depth %lu best: command %zu (score %.2f) - type: %s\n",
printf(" Depth %lu best: command %zu (score %.2f) - %s\n",
currentDepth - 1,
previousBestCommand,
scoresByDepth[previousBestCommand][currentDepth - 1],
net::eagle0::shardok::common::CommandType_Name(
(*commands)[previousBestCommand]->GetCommandType())
.c_str());
printf(" Depth %lu best: command %zu (score %.2f) - type: %s\n",
commands[previousBestCommand].DebugString().c_str());
printf(" Depth %lu best: command %zu (score %.2f) - %s\n",
currentDepth,
currentBestCommand,
currentBestScore,
net::eagle0::shardok::common::CommandType_Name(
(*commands)[currentBestCommand]->GetCommandType())
.c_str());
commands[currentBestCommand].DebugString().c_str());
#endif
}
@@ -249,7 +244,7 @@ auto IterativeDeepeningAI::IterativeSearch(
result.searchCompleted = result.minimumDepthCompleted;
result.timeUsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - startTime);
result.availableCommandCount = commands->size();
result.availableCommandCount = commands.size();
result.commandCountEvaluated = evaluatedCountAtHighestDepth;
result.completionReason = completionReason;
@@ -274,7 +269,7 @@ auto IterativeDeepeningAI::SearchCommandAtDepthWithEngine(
const ShardokEngine& guessedEngine,
const AIScoreCalculator& scorer,
const int maxRepeatCount,
const CommandListSPtr& commands,
const std::vector<CommandProto>& commands,
const size_t commandIndex,
const int desiredDepth,
const ScoreValue currentUtility,
@@ -284,10 +279,10 @@ auto IterativeDeepeningAI::SearchCommandAtDepthWithEngine(
result.depthAchieved = desiredDepth;
result.searchCompleted = true;
result.minimumDepthCompleted = true;
result.availableCommandCount = commands->size();
result.availableCommandCount = commands.size();
result.commandCountEvaluated = 1; // We're evaluating just this command
if (commandIndex >= commands->size()) {
if (commandIndex >= commands.size()) {
result.bestScore = 0.0;
std::promise<SearchResult> p;
p.set_value(result);
@@ -14,15 +14,16 @@
#include "src/main/cpp/net/eagle0/shardok/ai/AIAttackLocations.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCommand.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/util/HexMapUtils.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
namespace shardok {
// Forward declarations
class ShardokEngine;
using ScoreValue = double;
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
using BattalionTypeGetter = std::function<BattalionTypeSPtr(BattalionTypeId)>;
/// Reason why AI evaluation completed at the achieved depth.
@@ -69,7 +70,7 @@ public:
[[nodiscard]] SearchResult IterativeSearch(
const GameSettingsSPtr& settings,
const GameStateW& state,
const CommandListSPtr& commands,
const std::vector<CommandProto>& commands,
const AITimeBudget& initialBudget) const;
private:
@@ -92,7 +93,7 @@ private:
const ShardokEngine& guessedEngine,
const AIScoreCalculator& scorer,
int maxRepeatCount,
const CommandListSPtr& commands,
const std::vector<CommandProto>& commands,
size_t commandIndex,
int desiredDepth,
ScoreValue currentUtility,
@@ -10,15 +10,7 @@
#define DEBUG_FLEE_DECISIONS
// Enable to dump game state and debug tree to /tmp for debugging
// #define ENABLE_MCTS_DEBUG_DUMP
#ifdef ENABLE_MCTS_DEBUG_DUMP
#include <chrono>
#include <fstream>
#include <iomanip>
#include <sstream>
#endif
#include <google/protobuf/util/message_differencer.h>
#include "AIAttackerStrategySelector.hpp"
#include "AIConfig.hpp"
@@ -53,7 +45,6 @@ auto RoundsRemaining(const GameSettingsSPtr &settings, const GameStateView &gsv)
ShardokAIClient::ShardokAIClient(
const PlayerId playerId,
const bool isDefender,
const bool isAllAiBattle,
const HexMap *hexMap,
const SettingsGetter &settings,
const AIAlgorithmType aiAlgorithmType,
@@ -61,7 +52,6 @@ ShardokAIClient::ShardokAIClient(
const mcts::MCTSConfig &mctsConfig)
: playerId(playerId),
isDefender(isDefender),
isAllAiBattle(isAllAiBattle),
aiAlgorithmType(aiAlgorithmType),
scoringCalculatorType(scoringCalculatorType),
alCache(std::make_unique<AttackLocationsCache>(hexMap, settings)),
@@ -90,55 +80,33 @@ ShardokAIClient::ShardokAIClient(
apdCache->ConsolidateThreadLocalCache_Racy();
}
void CheckCommand(const CommandSPtr &realCommand, const CommandSPtr &guessedCommand) {
// Verify that the AI's guessed state produces the same available commands as the real state.
// We only compare fields that uniquely identify a command - metadata fields like action_points,
// will_unhide, next_round_target_info are not part of command identity.
void CheckCommand(const CommandProto &realDescriptor, const CommandProto &guessedDescriptor) {
string diff;
auto differencer = google::protobuf::util::MessageDifferencer();
differencer.IgnoreField(CommandProto::descriptor()->FindFieldByNumber(
CommandProto::kFollowUpCommandTypesFieldNumber));
differencer.ReportDifferencesToString(&diff);
if (!differencer.Compare(realDescriptor, guessedDescriptor)) {
printf("diff: %s\n\n", diff.c_str());
if (realCommand->GetCommandType() != guessedCommand->GetCommandType()) {
throw ShardokInternalErrorException("Command type mismatch between real and guessed state");
}
if (realCommand->GetPlayerId() != guessedCommand->GetPlayerId()) {
throw ShardokInternalErrorException("Player ID mismatch between real and guessed state");
}
if (realCommand->GetActorUnitId() != guessedCommand->GetActorUnitId()) {
throw ShardokInternalErrorException("Actor unit mismatch between real and guessed state");
}
if (realCommand->GetTargetRow() != guessedCommand->GetTargetRow() ||
realCommand->GetTargetColumn() != guessedCommand->GetTargetColumn()) {
throw ShardokInternalErrorException(
"Target coordinates mismatch between real and guessed state");
}
// For commands with odds (like FLEE), verify the odds match
if (realCommand->HasOdds() != guessedCommand->HasOdds()) {
throw ShardokInternalErrorException(
"Odds presence mismatch between real and guessed state");
}
if (realCommand->HasOdds() && guessedCommand->HasOdds()) {
if (realCommand->GetOddsPercentile() != guessedCommand->GetOddsPercentile()) {
throw ShardokInternalErrorException(
"Odds percentile mismatch between real and guessed state");
}
printf("Selected command descriptor\n%s\ndoes not match guessed\n%s\n\n",
realDescriptor.DebugString().c_str(),
guessedDescriptor.DebugString().c_str());
throw ShardokInternalErrorException("Illegal state for AI client");
}
}
auto ShardokAIClient::StandardChooseCommandIndex(
const GameSettingsSPtr &settings,
const GameStateW &guessedState,
const CommandListSPtr &realAvailableCommands) const -> CommandChoiceResults {
const vector<CommandProto> &realAvailableCommands) const -> CommandChoiceResults {
const auto settingsGetter = settings->GetGetter();
const auto guessedEngine = ShardokEngine(settings, guessedState);
const auto guessedCommands = guessedEngine.GetAvailableCommandsForAIPlayer(playerId);
const auto commandCount = guessedCommands->size();
const auto guessedCommands = guessedEngine.GetAvailableCommandProtos(playerId, false);
const auto commandCount = guessedCommands.size();
// Calculate time budget based on game situation using new dynamic per-command settings
const auto timeBudget =
CalculateTimeBudget(playerId, settings, guessedState, commandCount, isAllAiBattle);
const auto timeBudget = CalculateTimeBudget(playerId, settings, guessedState, commandCount);
// Configure MCTS based on proximity to enemy
// When far from enemy: use AVERAGING with maxPlayerFlips=0 (single-player lookahead)
@@ -148,14 +116,6 @@ auto ShardokAIClient::StandardChooseCommandIndex(
// - MINIMAX correctly models opponent choosing best response
// - Explores through one opponent turn for tactical accuracy
auto adjustedMCTSConfig = mctsConfig;
// For fair evaluation: simulate leaves to opponent's turn start (maxSimulationFlips=1)
// This ensures all leaves are scored at the same game phase:
// - Leaves at playerFlips=0 (still my turn): simulate through END_TURN to playerFlips=1
// - Leaves at playerFlips=1 (opponent's turn): evaluate immediately
// Result: consistent comparison of "what happens after I end my turn"
// adjustedMCTSConfig.maxSimulatfixionFlips = 1;
// adjustedMCTSConfig.maxPlayerFlips = 0;
// if (timeBudget.isCloseToEnemy) {
// adjustedMCTSConfig.maxPlayerFlips = 1;
@@ -171,10 +131,9 @@ auto ShardokAIClient::StandardChooseCommandIndex(
// }
// }
assert(commandCount == realAvailableCommands->size());
// Verify that the AI's guessed state produces the same available commands as reality
assert(commandCount == realAvailableCommands.size());
for (size_t i = 0; i < commandCount; i++) {
CheckCommand((*realAvailableCommands)[i], (*guessedCommands)[i]);
CheckCommand(realAvailableCommands[i], guessedCommands[i]);
}
// Extract values directly from settings for strategy selection
@@ -221,37 +180,6 @@ auto ShardokAIClient::StandardChooseCommandIndex(
IterativeDeepeningAI::SearchResult search_result;
if (aiAlgorithmType == AIAlgorithmType::MCTS) {
#ifdef ENABLE_MCTS_DEBUG_DUMP
// Set unique debug dump path for each action using timestamp
const auto now = std::chrono::system_clock::now();
const auto nowTime = std::chrono::system_clock::to_time_t(now);
const auto nowMs =
std::chrono::duration_cast<std::chrono::milliseconds>(now.time_since_epoch()) %
1000;
std::ostringstream pathStream;
pathStream << "/tmp/shardok_debug_"
<< std::put_time(std::localtime(&nowTime), "%Y%m%d_%H%M%S") << "_"
<< std::setfill('0') << std::setw(3) << nowMs.count() << "_p"
<< static_cast<int>(playerId) << ".txt";
adjustedMCTSConfig.debugDumpPath = pathStream.str();
// Also dump the game state to a file for reproduction
std::ostringstream statePathStream;
statePathStream << "/tmp/shardok_state_"
<< std::put_time(std::localtime(&nowTime), "%Y%m%d_%H%M%S") << "_"
<< std::setfill('0') << std::setw(3) << nowMs.count() << "_p"
<< static_cast<int>(playerId) << ".bin";
const std::string statePath = statePathStream.str();
// Write the flatbuffer game state to file using SaveTo method
if (guessedState.SaveTo(statePath)) {
printf("Game state dumped to: %s\n", statePath.c_str());
} else {
printf("Failed to dump game state to: %s\n", statePath.c_str());
}
#endif // ENABLE_MCTS_DEBUG_DUMP
// Using Monte Carlo Tree Search AI (with abstraction layer)
ShardokMCTSAI ai(
playerId,
@@ -291,8 +219,7 @@ auto ShardokAIClient::StandardChooseCommandIndex(
result.commandCountEvaluated,
result.availableCommandCount);
}
const auto chosenCommandType =
(*realAvailableCommands)[result.chosenIndex]->GetCommandType();
const auto chosenCommandType = realAvailableCommands[result.chosenIndex].type();
printf("ID AI: Search complete - achieved depth %d for best command %zu (%s)\n",
result.depthAchieved,
result.chosenIndex,
@@ -307,20 +234,19 @@ auto ShardokAIClient::StandardChooseCommandIndex(
auto ShardokAIClient::LateRoundAttackerChooseCommandIndex(
const GameSettingsSPtr &settings,
const GameStateW &guessedState,
const CommandListSPtr &realAvailableCommands) const -> CommandChoiceResults {
const vector<CommandProto> &realAvailableCommands) const -> CommandChoiceResults {
if (const auto dismissCommand = std::ranges::find_if(
*realAvailableCommands,
[](const CommandSPtr &cmd) {
return cmd->GetCommandType() ==
net::eagle0::shardok::common::DISMISS_UNIT_COMMAND;
realAvailableCommands,
[](const net::eagle0::shardok::api::CommandDescriptor &cmd) {
return cmd.type() == net::eagle0::shardok::common::DISMISS_UNIT_COMMAND;
});
dismissCommand == realAvailableCommands->end()) {
dismissCommand == realAvailableCommands.end()) {
return StandardChooseCommandIndex(settings, guessedState, realAvailableCommands);
} else {
CommandChoiceResults results{};
results.chosenIndex =
static_cast<size_t>(std::distance(realAvailableCommands->begin(), dismissCommand));
results.availableCommandCount = realAvailableCommands->size();
static_cast<size_t>(std::distance(realAvailableCommands.begin(), dismissCommand));
results.availableCommandCount = realAvailableCommands.size();
results.depthAchieved = 1; // Simple heuristic choice
results.commandCountEvaluated = 1; // Only evaluated one command type
results.completionReason =
@@ -332,13 +258,14 @@ auto ShardokAIClient::LateRoundAttackerChooseCommandIndex(
auto ShardokAIClient::FinalRoundAttackerChooseCommandIndex(
const GameSettingsSPtr &settings,
const GameStateW &guessedState,
const CommandListSPtr &realAvailableCommands) const -> CommandChoiceResults {
const auto fleeCommand =
std::ranges::find_if(*realAvailableCommands, [](const CommandSPtr &cmd) {
return cmd->GetCommandType() == net::eagle0::shardok::common::FLEE_COMMAND;
const vector<CommandProto> &realAvailableCommands) const -> CommandChoiceResults {
const auto fleeCommand = std::ranges::find_if(
realAvailableCommands,
[](const net::eagle0::shardok::api::CommandDescriptor &cmd) {
return cmd.type() == net::eagle0::shardok::common::FLEE_COMMAND;
});
if (fleeCommand == realAvailableCommands->end()) {
if (fleeCommand == realAvailableCommands.end()) {
return LateRoundAttackerChooseCommandIndex(settings, guessedState, realAvailableCommands);
}
@@ -367,7 +294,7 @@ auto ShardokAIClient::FinalRoundAttackerChooseCommandIndex(
if (fleeDecision.shouldFlee) {
CommandChoiceResults results{};
results.chosenIndex = fleeDecision.commandIndex;
results.availableCommandCount = realAvailableCommands->size();
results.availableCommandCount = realAvailableCommands.size();
results.depthAchieved = 1; // Heuristic choice
results.commandCountEvaluated = 1; // Only evaluated one command type
results.completionReason = EvaluationCompletionReason::RAN_OUT_OF_COMMANDS;
@@ -381,7 +308,7 @@ auto ShardokAIClient::FinalRoundAttackerChooseCommandIndex(
auto ShardokAIClient::ChooseCommandIndex(
const GameSettingsSPtr &settings,
const GameStateView &gsv,
const CommandListSPtr &realAvailableCommands) const -> CommandChoiceResults {
const vector<CommandProto> &realAvailableCommands) const -> CommandChoiceResults {
static int typeChosenCount[net::eagle0::shardok::common::CommandType_MAX + 1];
static int totalChoices = 0;
@@ -400,7 +327,7 @@ auto ShardokAIClient::ChooseCommandIndex(
results = StandardChooseCommandIndex(settings, guessedState, realAvailableCommands);
}
const auto chosenType = (*realAvailableCommands)[results.chosenIndex]->GetCommandType();
const auto chosenType = realAvailableCommands[results.chosenIndex].type();
typeChosenCount[static_cast<int>(chosenType)]++;
totalChoices++;
@@ -426,8 +353,8 @@ auto ShardokAIClient::ChooseCommandIndex(
auto ShardokAIClient::ChooseCommandIndex(const ShardokEngine &engine) const
-> CommandChoiceResults {
if (const auto &availableCommands = engine.GetAvailableCommandsForAIPlayer(playerId);
availableCommands->empty()) {
if (const auto &availableCommands = engine.GetAvailableCommandProtos(playerId, false);
availableCommands.empty()) {
printf("no commands for player %d\n", playerId);
throw ShardokInternalErrorException(
"Asked to choose a command, but there are none available");
@@ -18,7 +18,6 @@
#include "src/main/cpp/net/eagle0/shardok/ai/AIWaterCrossingCommandChooser.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/IterativeDeepeningAI.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/score/AIScoreCalculator.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCommand.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/game_state_view.pb.h"
namespace shardok {
@@ -41,7 +40,6 @@ class ShardokAIClient {
private:
const PlayerId playerId;
const bool isDefender;
const bool isAllAiBattle; // Whether this battle has only AI players (for faster time budgets)
const AIAlgorithmType aiAlgorithmType;
const ScoringCalculatorType scoringCalculatorType;
@@ -56,21 +54,25 @@ private:
[[nodiscard]] auto StandardChooseCommandIndex(
const GameSettingsSPtr& settings,
const GameStateW& guessedState,
const CommandListSPtr& realAvailableCommands) const -> CommandChoiceResults;
const vector<CommandProto>& realAvailableCommands) const -> CommandChoiceResults;
[[nodiscard]] auto LateRoundAttackerChooseCommandIndex(
const GameSettingsSPtr& settings,
const GameStateW& guessedState,
const CommandListSPtr& realAvailableCommands) const -> CommandChoiceResults;
const vector<CommandProto>& realAvailableCommands) const -> CommandChoiceResults;
[[nodiscard]] auto FinalRoundAttackerChooseCommandIndex(
const GameSettingsSPtr& settings,
const GameStateW& guessedState,
const CommandListSPtr& realAvailableCommands) const -> CommandChoiceResults;
const vector<CommandProto>& realAvailableCommands) const -> CommandChoiceResults;
[[nodiscard]] auto ChooseCommandIndex(
const GameSettingsSPtr& settings,
const net::eagle0::shardok::api::GameStateView& gsv,
const vector<CommandProto>& realAvailableCommands) const -> CommandChoiceResults;
public:
explicit ShardokAIClient(
PlayerId playerId,
bool isDefender,
bool isAllAiBattle,
const HexMap* hexMap,
const SettingsGetter& settings,
AIAlgorithmType aiAlgorithmType,
@@ -83,12 +85,6 @@ public:
[[nodiscard]] auto ChooseCommandIndex(const ShardokEngine& engine) const
-> CommandChoiceResults;
// Overload that works on copies of state - allows caller to release lock during AI thinking
[[nodiscard]] auto ChooseCommandIndex(
const GameSettingsSPtr& settings,
const net::eagle0::shardok::api::GameStateView& gsv,
const CommandListSPtr& realAvailableCommands) const -> CommandChoiceResults;
// MCTS configuration methods (only relevant when using MCTS algorithm)
[[nodiscard]] auto GetMCTSConfig() const -> const mcts::MCTSConfig& { return mctsConfig; }
void SetMCTSConfig(const mcts::MCTSConfig& config) { mctsConfig = config; }
@@ -4,7 +4,6 @@
#include "TranspositionTable.hpp"
#include <cinttypes>
#include <cstdio>
#include <cstring>
@@ -102,10 +101,10 @@ void TranspositionTable::clear() {
void TranspositionTable::printStats() const {
printf("TranspositionTable Stats:\n");
printf(" Probes: %" PRIu64 "\n", stats.probes.load());
printf(" Hits: %" PRIu64 " (%.1f%%)\n", stats.hits.load(), stats.hitRate());
printf(" Stores: %" PRIu64 "\n", stats.stores.load());
printf(" Collisions: %" PRIu64 "\n", stats.collisions.load());
printf(" Probes: %llu\n", stats.probes.load());
printf(" Hits: %llu (%.1f%%)\n", stats.hits.load(), stats.hitRate());
printf(" Stores: %llu\n", stats.stores.load());
printf(" Collisions: %llu\n", stats.collisions.load());
printf(" Table size: %zu entries (%.1f MB)\n",
TABLE_SIZE,
(TABLE_SIZE * sizeof(TTEntry)) / (1024.0 * 1024.0));
@@ -20,5 +20,6 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/library:shardok_c_types",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
],
)
@@ -1,813 +0,0 @@
# Chance Nodes in MCTS for Shardok
## Problem Statement
### Current Behavior
The current MCTS implementation uses a fixed roll (50th percentile) for all probabilistic outcomes during simulation. This creates several issues:
1. **Binary success actions overvalued**: A START_FIRE command with 51% success is treated as always succeeding, making it appear better than it actually is.
2. **Discontinuity at 50%**: Actions with 49% vs 51% success have dramatically different evaluations, when they should be similar.
3. **Variable-outcome actions simplified**: Melee/archery attacks with damage ranges are evaluated at a single point rather than their full distribution.
### Example Issue
```
START_FIRE with 51% success:
- Current MCTS: Assumes always succeeds (roll = 50)
- Reality: Succeeds 51% of time, fails 49% of time
- Result: AI overvalues this action
```
### How Iterative Deepening Solves This
The iterative deepening AI (see `AICommandEvaluator.cpp:352-393`) handles randomness correctly:
```cpp
// For actions with odds (binary success/fail):
// 1. Evaluate success outcome with representative roll
auto [successScore, successLookahead] = EvaluateWithRandomness(
...,
std::make_shared<SequenceRandomGenerator>(std::vector{1.0 - successChance / 2.0})
);
// 2. Evaluate failure outcome with representative roll
auto [failureScore, failureLookahead] = EvaluateWithRandomness(
...,
std::make_shared<SequenceRandomGenerator>(std::vector{(1.0 - successChance) / 2.0})
);
// 3. Compute weighted average (expected value)
immediateScore = std::lerp(failureScore, successScore, successChance);
lookaheadScore = std::lerp(failureLookahead.get(), successLookahead.get(), successChance);
```
This is essentially an implicit form of chance nodes - evaluating both outcomes and weighting by probability.
## Chance Nodes Concept
### Classic MCTS with Chance Nodes
In games with randomness (e.g., backgammon), MCTS uses two types of nodes:
1. **Decision Nodes**: Player chooses an action
- Selection uses UCB formula (exploration/exploitation tradeoff)
- One child per legal action
2. **Chance Nodes**: Nature determines outcome
- Selection uses expectation (weighted by probability)
- One child per possible outcome
```
Decision Node (Player to move)
├─ Action A
│ └─ Chance Node
│ ├─ Outcome 1 (prob 0.3) → Game State
│ ├─ Outcome 2 (prob 0.5) → Game State
│ └─ Outcome 3 (prob 0.2) → Game State
└─ Action B
└─ Deterministic → Game State
```
### Example: START_FIRE in Shardok
**Current approach:**
```
State S
└─ START_FIRE (roll=50)
└─ State S' (fire always starts)
```
**With chance nodes:**
```
State S
└─ START_FIRE action
└─ Chance Node
├─ Success (51%) → State S_success (fire started)
└─ Failure (49%) → State S_failure (no fire, vigor spent)
```
### Value Propagation
**Decision nodes:** Maximize/minimize over children (depending on player)
**Chance nodes:** Expected value over children (weighted by probability)
```cpp
// Decision node value (max for current player)
value = max(child.value for child in children)
// Chance node value (expectation)
value = sum(prob[i] * child[i].value for i in outcomes)
```
## Implementation Approaches
### Option 1: Explicit Chance Nodes (Full Implementation)
Modify the MCTS tree structure to explicitly represent chance nodes.
**Pros:**
- Theoretically sound
- Handles arbitrary outcome distributions
- Clear separation of decision vs chance
**Cons:**
- Significant code changes
- Larger tree (more memory)
- More complex tree traversal
**Tree Structure:**
```cpp
enum class NodeType { DECISION, CHANCE };
struct MCTSNode {
NodeType type;
// For decision nodes
MCTSPlayerId player;
std::vector<std::unique_ptr<MCTSAction>> actions;
std::vector<std::unique_ptr<MCTSNode>> children; // One per action
// For chance nodes
std::vector<double> probabilities; // One per outcome
std::vector<std::unique_ptr<MCTSNode>> outcomes; // One per outcome
double visits;
double totalReward;
};
```
**Selection Phase:**
```cpp
MCTSNode* select(MCTSNode* node) {
while (!node->isLeaf()) {
if (node->type == DECISION) {
// Use UCB to select action
node = selectChildUCB(node);
} else { // CHANCE node
// Use probability-weighted selection
node = selectOutcomeByProbability(node);
}
}
return node;
}
```
**Backpropagation:**
```cpp
void backpropagate(MCTSNode* node, double reward) {
while (node != nullptr) {
node->visits++;
if (node->type == DECISION) {
node->totalReward += reward; // Sum for averaging
} else { // CHANCE node
node->totalReward += reward; // Still sum, but averaged differently
}
node = node->parent;
}
}
```
### Option 2: Implicit Chance Nodes (Hybrid Approach)
Keep the current tree structure but sample outcomes during expansion/simulation.
**Pros:**
- Smaller code changes
- More memory efficient
- Easier to implement incrementally
**Cons:**
- Less theoretically pure
- May need more visits to converge
- Sampling introduces variance
**Approach:**
```cpp
// During expansion
std::unique_ptr<MCTSGameState> expand(
const MCTSGameState& state,
const MCTSAction& action
) {
if (action.isDeterministic()) {
return applyActionDeterministic(state, action);
} else {
// Sample an outcome based on probabilities
auto outcome = sampleOutcome(action);
return applyActionWithOutcome(state, action, outcome);
}
}
```
**For binary actions (e.g., START_FIRE):**
```cpp
// Expand creates one of two children based on sampling
if (random() < successProbability) {
return applySuccess(state, action);
} else {
return applyFailure(state, action);
}
// Over many visits, visit ratio will approach probability ratio
// E.g., 51% success action will have ~51% success children, 49% failure children
```
### Option 3: Determinized Sampling (Simplest)
Pre-sample all random outcomes at the start of each simulation rollout.
**Pros:**
- Minimal code changes
- Easy to understand
- Works with existing tree structure
**Cons:**
- May converge slowly
- Doesn't explicitly represent probability
- Can waste simulations on unlikely outcomes
**Approach:**
```cpp
// At start of each simulation
std::vector<double> rollSequence = generateRollSequence(maxDepth);
// Use sequence during simulation
auto state = rootState;
for (int depth = 0; depth < maxDepth; depth++) {
auto action = selectAction(state);
state = applyAction(state, action, rollSequence[depth]);
}
```
## Recommended Approach: Progressive Enhancement
Implement in phases to manage complexity:
### Phase 1: Binary Chance Nodes (Explicit)
Start with actions that have clear success/failure outcomes (e.g., START_FIRE, EXTINGUISH_FIRE, RAISE_DEAD):
1. Identify binary actions (commands with `HasOdds()`)
2. Add chance node support for these actions only
3. Modify tree expansion to create chance nodes
4. Update selection/backpropagation for chance nodes
**Implementation:**
```cpp
// In ShardokGameEngine::getLegalActions()
// Mark which actions require chance nodes
struct ActionMetadata {
std::unique_ptr<MCTSAction> action;
bool requiresChanceNode;
double successProbability; // If requiresChanceNode = true
};
```
```cpp
// In tree expansion
if (action.requiresChanceNode) {
// Create chance node with two children
auto chanceNode = std::make_unique<MCTSNode>(CHANCE);
chanceNode->probabilities = {successProb, 1.0 - successProb};
// Expand both outcomes
chanceNode->outcomes.push_back(applySuccess(state, action));
chanceNode->outcomes.push_back(applyFailure(state, action));
return chanceNode;
} else {
// Normal deterministic expansion
return applyAction(state, action);
}
```
### Phase 2: Multi-Outcome Actions
Extend to actions with multiple outcomes (e.g., melee damage ranges):
1. Discretize continuous distributions into buckets
2. For melee/archery, use 3-5 representative damage values (min, low, avg, high, max)
3. Compute probabilities for each bucket
4. Create chance nodes with multiple children
**Example: Melee Attack**
```cpp
// Instead of sampling full damage distribution,
// use representative values
struct DamageBucket {
int damageValue; // Representative damage
double probability; // Probability of this range
};
// For a melee attack that can deal 10-20 damage
std::vector<DamageBucket> buckets = {
{10, 0.1}, // Min damage (unlucky)
{13, 0.2}, // Low damage
{15, 0.4}, // Average damage
{17, 0.2}, // High damage
{20, 0.1} // Max damage (lucky)
};
```
### Phase 3: Optimization
Once chance nodes work correctly:
1. Add transposition table support for chance nodes
2. Optimize memory layout
3. Consider progressive widening (start with 2 outcomes, expand to more if visited often)
4. Profile and tune
## Design Decisions
### How to Represent Outcomes?
**Option A: Explicit state copies**
```cpp
struct ChanceNode {
std::vector<std::unique_ptr<MCTSGameState>> outcomeStates;
std::vector<double> probabilities;
};
```
**Option B: Lazy evaluation**
```cpp
struct ChanceNode {
MCTSGameState baseState;
MCTSAction action;
std::vector<int> outcomeRolls; // Roll values for each outcome
std::vector<double> probabilities;
// Compute state on-demand
MCTSGameState getOutcome(size_t index) {
return applyActionWithRoll(baseState, action, outcomeRolls[index]);
}
};
```
**Recommendation:** Option B - lazy evaluation. Only materialize states when visited.
### How Many Outcomes per Action?
**Binary actions (START_FIRE, etc.):**
- Exactly 2 outcomes (success/fail)
- Use exact probabilities from `GetOddsPercentile()`
**Damage actions (MELEE, ARCHERY):**
- Start with 3 outcomes (low/med/high)
- Can expand to 5 if needed for accuracy
- Use representative rolls: 10th, 50th, 90th percentile
**Complex actions (METEOR):**
- Consider 2-3 outcomes initially
- Can model as "hits N enemies" for N in {0, 1, 2, 3+}
### How to Handle Transposition Table?
**Challenge:** Same state can be reached via different chance outcomes
**Solution:**
- Hash based on game state only (not the path taken)
- When looking up, return cached evaluation if state matches
- This is already how transposition tables work!
```cpp
// Current approach works fine:
auto hash = computeHash(gameState); // Doesn't include how we got here
if (auto cached = transpositionTable.lookup(hash)) {
return cached->value;
}
```
### Selection at Chance Nodes
**During tree traversal:**
```cpp
size_t selectOutcome(const ChanceNode& node) {
// Option 1: Sample by probability (introduces variance)
double r = random();
double cumulative = 0.0;
for (size_t i = 0; i < node.probabilities.size(); i++) {
cumulative += node.probabilities[i];
if (r < cumulative) return i;
}
// Option 2: Round-robin weighted by visit count vs probability
// (Explore under-visited outcomes more)
size_t leastVisited = findMostUnderExploredOutcome(node);
return leastVisited;
}
```
**Recommendation:** Use Option 2 to ensure all outcomes get explored proportionally.
## Integration Points
### Modified Functions
1. **`ShardokGameEngine::getLegalActions()`**
- Add metadata about which actions need chance nodes
- Return action + probability information
2. **`ShardokGameEngine::applyAction()`**
- For binary actions, return both possible outcomes
- Or: take an explicit outcome index parameter
3. **`AbstractMCTSAI::selection()`**
- Handle chance nodes differently from decision nodes
- Use probability-weighted selection instead of UCB
4. **`AbstractMCTSAI::expand()`**
- Create chance node children for probabilistic actions
- May create multiple child nodes per action
5. **`AbstractMCTSAI::backpropagate()`**
- Update all nodes in path (both decision and chance)
- Value calculation already handles this correctly (just averages)
### New Functions Needed
```cpp
// In ShardokGameEngine
struct ChanceOutcome {
int roll; // The dice roll that produces this outcome
double probability; // Probability of this outcome
};
std::vector<ChanceOutcome> getChanceOutcomes(const MCTSAction& action) const;
```
```cpp
// In MCTSNode
bool isChanceNode() const;
const std::vector<double>& getOutcomeProbabilities() const;
```
## Testing Strategy
### Unit Tests
1. **Binary action correctness**
```cpp
TEST(ChanceNodes, BinaryActionExpectedValue) {
// START_FIRE with 60% success
// Run MCTS with chance nodes
// Verify: visits to success ~= 60%, visits to failure ~= 40%
// Verify: expected value matches manual calculation
}
```
2. **Comparison with iterative deepening**
```cpp
TEST(ChanceNodes, MatchesIterativeDeepening) {
// Same position, both AIs
// Should choose same action
// Scores should be similar (within variance)
}
```
3. **Transposition table with chance**
```cpp
TEST(ChanceNodes, TranspositionConsistency) {
// Two paths to same state via different chance outcomes
// Should reuse cached evaluation
}
```
### Integration Tests
1. Compare MCTS with/without chance nodes on test positions
2. Verify that chance nodes reduce overvaluation of marginal actions
3. Performance test: measure slowdown (expect 1.5-2x for binary actions)
### Real-World Validation
Run the problematic START_FIRE scenario:
- With current MCTS: Should overvalue START_FIRE
- With chance nodes: Should correctly weight success/failure
- Expected: END_TURN should get significantly more visits
## Performance Considerations
### Memory Overhead
**Per chance node:**
- Probability vector: `N * sizeof(double)` (N = number of outcomes)
- Outcome children: `N * sizeof(unique_ptr)`
- For binary: ~32 bytes per chance node
**Estimate:**
- Current tree: ~100K nodes per search
- With chance nodes: ~150K nodes (50% actions are probabilistic)
- Extra memory: ~50K * 32 bytes = ~1.6 MB
- **Acceptable overhead**
### Computational Overhead
**Per simulation:**
- Current: 1 path through tree
- With chance nodes: Still 1 path, but more nodes
- Overhead: ~20-30% (more node visits)
**Mitigation:**
- Transposition table helps (same states via different paths)
- Progressive widening (start with 2 outcomes, expand if visited often)
- Lazy state evaluation (don't materialize until needed)
### Convergence Speed
Chance nodes may require more visits to converge because:
- More children per action (branching factor increases)
- Outcomes need proportional exploration
**Mitigation:**
- Use visit count thresholds before expanding chance nodes
- Consider progressive widening (UCT-ProgressiveWidening)
## Migration Path
### Step 1: Infrastructure (1-2 days)
- Add `NodeType` enum and metadata to MCTSNode
- Implement chance node creation (without using them yet)
- Add unit tests for chance node structure
### Step 2: Binary Actions (2-3 days)
- Identify all binary success/fail actions
- Modify expansion to create chance nodes for these
- Update selection/backpropagation
- Test on START_FIRE scenario
### Step 3: Integration Testing (1 day)
- Run full MCTS tests with chance nodes enabled
- Compare with iterative deepening on test positions
- Validate that it fixes the START_FIRE overvaluation
### Step 4: Multi-Outcome Actions (2-3 days)
- Implement damage bucketing for MELEE/ARCHERY
- Create chance nodes with 3-5 outcomes
- Test on combat scenarios
### Step 5: Optimization (1-2 days)
- Profile performance
- Add progressive widening if needed
- Tune outcome granularity
### Step 6: Documentation & Cleanup (1 day)
- Document the new approach
- Clean up code
- Add comprehensive tests
## Alternative: Simpler Hybrid Approach
If full chance nodes are too complex, consider a hybrid:
1. **Keep current tree structure** (no explicit chance nodes)
2. **During expansion:** Sample outcome and create one child
3. **Over many simulations:** Statistics converge to correct probabilities
4. **Add outcome tracking:** Store "which outcome" in edge/node metadata
**Example:**
```cpp
// Expansion samples an outcome
auto expand(state, action) {
if (action.hasBinaryOutcome()) {
// Sample once
bool success = (random() < successProb);
// Store which outcome this edge represents
edge.metadata.outcome = success ? OUTCOME_SUCCESS : OUTCOME_FAILURE;
return applyWithOutcome(state, action, success);
}
}
// Selection prioritizes under-explored outcomes
auto selectChild(node) {
// Find action where outcome distribution is unbalanced
// E.g., 60% success action should have ~60% success children
// If we have 80% success children, prefer exploring failure
}
```
This is simpler but less theoretically sound. It's a reasonable starting point if full chance nodes prove too complex.
## Comparison: Chance Nodes vs Open-Loop MCTS
### What is Open-Loop MCTS?
**Open-loop MCTS** (also called "determinization MCTS" or "information set MCTS") is an alternative approach to handling randomness:
1. At the **start of each simulation**, sample all random outcomes needed for that simulation
2. Play out the entire simulation using those fixed random values
3. Different simulations use different random seeds
4. The tree structure doesn't explicitly model randomness - it's all in the rollouts
**Example implementation:**
```cpp
// At start of simulation
std::vector<double> rollSequence = sampleRolls(maxDepth); // Pre-sample all rolls
// During simulation
MCTSNode* node = root;
for (int depth = 0; depth < maxDepth; depth++) {
Action action = selectAction(node);
node = applyAction(node, action, rollSequence[depth]); // Use pre-sampled roll
}
```
### Open-Loop MCTS for Shardok
**How it would work:**
```cpp
// Each simulation samples a "possible world"
void simulate(MCTSNode* root) {
// Sample random rolls for this simulation
auto rolls = generateRollSequence(); // e.g., {0.45, 0.78, 0.23, ...}
// Play out simulation using these fixed rolls
auto state = root->state;
for (int depth = 0; depth < maxDepth; depth++) {
auto action = selectAction(state);
state = applyAction(state, action, rolls[depth]);
}
double reward = evaluate(state);
backpropagate(root, reward);
}
```
**Would this fix the START_FIRE issue?**
**Yes** - partially. Different simulations would see different outcomes:
- Some simulations: START_FIRE succeeds (roll < 0.51)
- Some simulations: START_FIRE fails (roll >= 0.51)
- Over many simulations, the action's value would approach the expected value
**However**, it's less efficient than chance nodes because:
- Needs MORE simulations to converge
- Wastes effort exploring unlikely scenarios equally with likely ones
- Doesn't explicitly guide exploration based on probability
### Detailed Comparison
| Aspect | Chance Nodes (Closed-Loop) | Open-Loop MCTS | Current (Fixed Roll) |
|--------|---------------------------|----------------|----------------------|
| **Randomness Handling** | Explicit in tree structure | Implicit in simulation sampling | Fixed roll=50 |
| **Convergence Speed** | Fast - probabilities guide search | Slower - needs more samples | N/A (wrong answer) |
| **Memory Usage** | Higher (more nodes) | Lower (no extra nodes) | Lowest |
| **Implementation Complexity** | High (tree structure changes) | Medium (sampling layer) | Low (current) |
| **Theoretical Soundness** | Highest (models true game tree) | Medium (approximation via sampling) | Low (assumes fixed outcome) |
| **START_FIRE Fix** | ✅ Yes, accurately | ✅ Yes, eventually | ❌ No |
| **Efficiency** | Most efficient per simulation | Less efficient (wasted samples) | Efficient but wrong |
| **Handles Hidden Information** | Poor | Excellent | N/A |
### When to Prefer Each Approach
**Prefer Chance Nodes when:**
- Randomness outcomes are discrete and enumerable (e.g., binary success/fail)
- Probabilities are known precisely
- You want fastest convergence to correct answer
- Game tree is the primary concern (no hidden information)
- **This is Shardok's situation**
**Prefer Open-Loop when:**
- Randomness is continuous and high-dimensional
- Hidden information or imperfect information is present
- Simplicity is paramount
- You can afford many simulations
- Used in games like poker, bridge, Skat
### Why Chance Nodes are Better for Shardok
1. **Discrete outcomes**: Most Shardok randomness is binary (success/fail) or small discrete sets (damage ranges)
- START_FIRE: 2 outcomes (success/fail)
- MELEE: Can bucket into 3-5 damage ranges
- Not continuous - perfect fit for chance nodes
2. **Known probabilities**: We have exact probabilities from `GetOddsPercentile()`
- Chance nodes can use exact probabilities
- Open-loop just samples blindly
3. **No hidden information**: Shardok is perfect information (all units visible to AI)
- Chance nodes' main weakness doesn't apply
- Open-loop's main strength doesn't help
4. **Convergence matters**: Limited simulation budget
- Need to converge quickly
- Chance nodes achieve this better
5. **Existing infrastructure**: We already have deterministic state transitions
- Adding chance nodes builds on what we have
- Open-loop would need different rollout structure
### Performance Analysis
**Chance Nodes:**
```
Time per simulation: 1.3x current
Simulations needed: 10,000 to converge
Total time: 13,000x units
Memory: 1.5x current (extra chance nodes)
```
**Open-Loop:**
```
Time per simulation: 1.0x current (same as now)
Simulations needed: 30,000 to converge (more variance)
Total time: 30,000x units
Memory: 1.0x current (no extra nodes)
```
**Result:** Chance nodes are **2.3x faster overall** despite being slower per simulation, because they converge with fewer simulations.
### Hybrid Approach: Best of Both Worlds?
Could we combine them?
**Idea:** Use chance nodes for high-probability branches, open-loop for rare events
```cpp
if (probability > 0.1 && outcomeCount <= 5) {
// Use explicit chance node
createChanceNode(outcomes, probabilities);
} else {
// Use open-loop sampling
sampleOutcome();
}
```
**Verdict:** Probably not worth the complexity. Shardok's randomness is simple enough that chance nodes handle everything well.
### Recommendation for Shardok
**Use Chance Nodes**, specifically:
1. **Phase 1:** Binary actions (START_FIRE, RAISE_DEAD, etc.)
- 2 outcomes, exact probabilities
- Biggest bang for buck
2. **Phase 2:** Damage ranges (MELEE, ARCHERY)
- 3-5 buckets
- Still manageable
3. **If needed:** Could fall back to open-loop for complex actions
- E.g., METEOR with many possible outcomes
- But likely unnecessary
### Why Not Open-Loop?
While open-loop would eventually fix the START_FIRE issue, it has significant downsides for Shardok:
1. **Slower convergence**: Needs 2-3x more simulations
2. **Doesn't leverage known probabilities**: We have exact odds, why ignore them?
3. **Less interpretable**: Harder to debug why AI chose an action
4. **Doesn't align with iterative deepening**: We want MCTS to match the proven algorithm
The only advantage of open-loop (simplicity) is outweighed by chance nodes' efficiency and correctness.
### Could We Use Current Approach + Better Sampling?
**Idea:** Keep fixed rolls but use different rolls per simulation?
```cpp
// Instead of always roll=50
double roll = random(); // Different each simulation
```
**Problem:** This is essentially open-loop without the tree!
- Even slower to converge
- Tree doesn't learn the outcome probabilities
- Worst of both worlds
**Verdict:** No, this doesn't help. If we're going to sample, do it properly (open-loop). Otherwise, use chance nodes.
### Final Verdict
**For Shardok, chance nodes are clearly superior:**
- ✅ Faster convergence (2-3x vs open-loop)
- ✅ Leverages exact probabilities
- ✅ Perfect fit for discrete outcomes
- ✅ Aligns with iterative deepening approach
- ✅ Better debuggability and interpretability
- ❌ More complex implementation (but manageable)
Open-loop would be a fallback if chance nodes prove too difficult, but given the benefits and the bounded complexity (only binary and small discrete outcomes), chance nodes are the right choice.
## Conclusion
Implementing chance nodes will fix the overvaluation of marginal probabilistic actions like START_FIRE with 51% success. The recommended approach is:
1. Start with **explicit chance nodes for binary actions**
2. Use **lazy state evaluation** to minimize memory
3. **Progressive enhancement** - binary first, then multi-outcome
4. Compare with iterative deepening to validate correctness
Expected benefits:
- More accurate action evaluation
- Better handling of probabilistic outcomes
- Closer alignment with theoretical MCTS
- Fixes the START_FIRE issue without tuning heuristics
Expected costs:
- ~20-30% slower per simulation (more nodes)
- ~1-2MB extra memory
- ~1-2 weeks development time
The benefits significantly outweigh the costs for a more theoretically sound and accurate AI.
@@ -20,6 +20,7 @@
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-redundant-constexpr-static-def"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
#pragma clang diagnostic pop
namespace shardok {
@@ -31,6 +32,7 @@ class AIScoreCalculator;
class ShardokMCTSAI {
public:
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
using SearchResult = IterativeDeepeningAI::SearchResult;
using MCTSConfig = mcts::MCTSConfig;
@@ -11,7 +11,8 @@ cc_library(
],
deps = [
"//src/main/cpp/net/eagle0/common/mcts/abstract:mcts_action",
"//src/main/cpp/net/eagle0/shardok/library:shardok_c_types",
"//src/main/cpp/net/eagle0/shardok/library:shardok_command",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
"//src/main/protobuf/net/eagle0/shardok/common:command_type_cc_proto",
],
)
@@ -32,7 +33,6 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/library:engine",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/protobuf/net/eagle0/shardok/common:command_type_cc_proto",
],
)
@@ -78,5 +78,6 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/library:engine",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
],
)
@@ -6,83 +6,101 @@
#include <sstream>
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCommand.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
namespace shardok::mcts {
namespace shardok {
namespace mcts {
// Constructor: extract and store just the essential fields
ShardokAction::ShardokAction(
size_t index,
CommandType type,
PlayerId player,
int actorId,
int targetRow,
int targetCol,
bool hasOdds)
: commandIndex_(index),
type_(type),
player_(player),
actorId_(actorId),
targetRow_(targetRow),
targetCol_(targetCol),
hasOdds_(hasOdds) {}
// New constructor: store pointer to command (preferred - no proto conversion!)
ShardokAction::ShardokAction(const ShardokCommand* command, size_t index)
: command_(command),
commandIndex_(index) {}
// Legacy constructors for compatibility
ShardokAction::ShardokAction(const CommandProto& command, size_t index)
: command_(nullptr),
commandProto_(command),
commandIndex_(index) {}
ShardokAction::ShardokAction(CommandProto&& command, size_t index)
: command_(nullptr),
commandProto_(std::move(command)),
commandIndex_(index) {}
// Lazy getter - converts to proto only when needed
const ShardokAction::CommandProto& ShardokAction::getCommand() const {
if (command_ != nullptr) {
// Lazily convert Command → Proto (only once)
if (commandProto_.type() == net::eagle0::shardok::common::UNKNOWN_COMMAND) {
commandProto_ = command_->GetCommandProto();
}
return commandProto_;
}
// Already have proto from legacy constructor
return commandProto_;
}
int ShardokAction::getType() const { return static_cast<int>(getCommand().type()); }
std::string ShardokAction::getDescription() const {
const auto& cmd = getCommand();
std::stringstream ss;
// Show player
ss << "P" << static_cast<int>(player_) << " ";
ss << "P" << static_cast<int>(cmd.player()) << " ";
ss << net::eagle0::shardok::common::CommandType_Name(type_);
ss << net::eagle0::shardok::common::CommandType_Name(cmd.type());
if (actorId_ >= 0) { ss << " Unit:" << actorId_; }
if (cmd.has_actor()) { ss << " Unit:" << cmd.actor().value(); }
if (targetRow_ >= 0 && targetCol_ >= 0) {
ss << " @(" << targetRow_ << "," << targetCol_ << ")";
if (cmd.has_target()) {
const auto& coord = cmd.target();
ss << " @(" << coord.row() << "," << coord.column() << ")";
}
return ss.str();
}
int ShardokAction::getActorId() const {
const auto& cmd = getCommand();
if (cmd.has_actor()) { return cmd.actor().value(); }
return -1;
}
std::pair<int, int> ShardokAction::getTarget() const {
const auto& cmd = getCommand();
if (cmd.has_target()) {
const auto& coord = cmd.target();
return std::make_pair(coord.row(), coord.column());
}
return std::make_pair(-1, -1);
}
std::unique_ptr<MCTSAction> ShardokAction::clone() const {
return std::make_unique<ShardokAction>(
commandIndex_,
type_,
player_,
actorId_,
targetRow_,
targetCol_,
hasOdds_);
// Clone by copying command pointer (cheap!) instead of proto
if (command_ != nullptr) { return std::make_unique<ShardokAction>(command_, commandIndex_); }
// Fallback: clone proto
return std::make_unique<ShardokAction>(commandProto_, commandIndex_);
}
bool ShardokAction::equals(const MCTSAction& other) const {
const auto* shardokOther = dynamic_cast<const ShardokAction*>(&other);
if (!shardokOther) { return false; }
// Compare by index only - actions from same command list are uniquely identified by index
return commandIndex_ == shardokOther->commandIndex_;
}
bool ShardokAction::requiresChanceNode() const {
// Actions with probabilistic outcomes require chance nodes:
// 1. Binary success/failure actions (hasOdds_): START_FIRE, FEAR, etc.
// 2. END_TURN: random effects (fire spread, weather changes)
// 3. Combat actions: roll affects damage dealt (MELEE, ARCHERY, CHARGE, DUEL)
if (hasOdds_) { return true; }
using namespace net::eagle0::shardok::common;
switch (type_) {
case END_TURN_COMMAND:
case MELEE_COMMAND:
case ARCHERY_COMMAND:
case CHARGE_COMMAND:
case CHALLENGE_DUEL_COMMAND:
case REDUCE_COMMAND: return true;
default: return false;
// Fast path: compare by command pointer
if (command_ != nullptr && shardokOther->command_ != nullptr) {
return commandIndex_ == shardokOther->commandIndex_ && command_ == shardokOther->command_;
}
// Fallback: compare protos
return commandIndex_ == shardokOther->commandIndex_ &&
getCommand().SerializeAsString() == shardokOther->getCommand().SerializeAsString();
}
} // namespace shardok::mcts
} // namespace mcts
} // namespace shardok
@@ -9,53 +9,51 @@
#include <string>
#include "src/main/cpp/net/eagle0/common/mcts/abstract/MCTSAction.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
#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"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
#pragma clang diagnostic pop
namespace shardok::mcts {
namespace shardok {
// Forward declaration
class ShardokCommand;
namespace mcts {
class ShardokAction : public MCTSAction {
public:
using CommandType = net::eagle0::shardok::common::CommandType;
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
// Constructor: store just the essential fields (no proto, no pointer)
ShardokAction(
size_t index,
CommandType type,
PlayerId player,
int actorId,
int targetRow,
int targetCol,
bool hasOdds);
// New constructor: store pointer to Command instead of copying proto
ShardokAction(const ShardokCommand* command, size_t index);
// Legacy constructors for compatibility (creates internal proto copy)
ShardokAction(const CommandProto& command, size_t index);
ShardokAction(CommandProto&& command, size_t index);
// MCTSAction interface implementation
[[nodiscard]] size_t getIndex() const override { return commandIndex_; }
[[nodiscard]] std::string getDescription() const override;
[[nodiscard]] std::unique_ptr<MCTSAction> clone() const override;
[[nodiscard]] bool equals(const MCTSAction& other) const override;
[[nodiscard]] bool requiresChanceNode() const override;
// Shardok-specific accessors (O(1), no allocations)
[[nodiscard]] int getType() const { return static_cast<int>(type_); }
[[nodiscard]] PlayerId getPlayer() const { return player_; }
[[nodiscard]] int getActorId() const { return actorId_; }
[[nodiscard]] std::pair<int, int> getTarget() const { return {targetRow_, targetCol_}; }
// Shardok-specific methods (not part of abstract interface)
[[nodiscard]] int getType() const;
[[nodiscard]] int getActorId() const;
[[nodiscard]] std::pair<int, int> getTarget() const;
// Shardok-specific accessor - lazily converts to proto if needed
[[nodiscard]] const CommandProto& getCommand() const;
private:
// Store only essential fields (~25 bytes, all POD, cache-friendly)
// Store pointer to command (preferred) OR proto copy (legacy)
const ShardokCommand* command_ = nullptr; // Non-owning pointer to cached command
mutable CommandProto commandProto_; // Lazy proto copy (only used if command_ is null)
size_t commandIndex_;
CommandType type_;
PlayerId player_;
int actorId_; // -1 if no actor
int targetRow_; // -1 if no target
int targetCol_; // -1 if no target
bool hasOdds_; // true if command has probabilistic outcome
};
} // namespace shardok::mcts
} // namespace mcts
} // namespace shardok
#endif // EAGLE0_SHARDOK_ACTION_HPP
@@ -4,9 +4,7 @@
#include "ShardokGameEngine.hpp"
#include <algorithm>
#include <chrono>
#include <numeric>
#include "ShardokAction.hpp"
#include "ShardokGameState.hpp"
@@ -16,26 +14,24 @@
#include "src/main/cpp/net/eagle0/shardok/ai/AIHeuristicWeighting.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/ShardokException.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
namespace shardok::mcts {
// Shared cache for legal actions (uses lock-free parallel hash map for thread safety)
// Using 8 submaps to reduce contention with 16 MCTS threads
gtl::parallel_flat_hash_map<
uint64_t,
ShardokGameEngine::LegalActionsCache,
std::hash<uint64_t>,
std::equal_to<uint64_t>,
std::allocator<std::pair<const uint64_t, ShardokGameEngine::LegalActionsCache>>,
8,
std::mutex>
// Deterministic random generator for predictable combat rolls (50th percentile)
// This matches the behavior used in IterativeDeepening AI
static const std::vector<double> kAverageSequence = {0.5};
static const auto kAverageGenerator = std::make_shared<SequenceRandomGenerator>(kAverageSequence);
// Thread-local cache for legal actions (avoids lock contention in multithreaded simulation)
thread_local gtl::flat_hash_map<uint64_t, ShardokGameEngine::LegalActionsCache>
ShardokGameEngine::legalActionsCache_;
std::atomic<uint64_t> ShardokGameEngine::cacheHits_{0};
std::atomic<uint64_t> ShardokGameEngine::cacheMisses_{0};
std::atomic<uint64_t> ShardokGameEngine::timeInHashComputation_{0};
std::atomic<uint64_t> ShardokGameEngine::timeInLegalActionsComputation_{0};
thread_local uint64_t ShardokGameEngine::cacheHits_ = 0;
thread_local uint64_t ShardokGameEngine::cacheMisses_ = 0;
thread_local uint64_t ShardokGameEngine::transitionCacheHits_ = 0;
thread_local uint64_t ShardokGameEngine::transitionCacheMisses_ = 0;
thread_local uint64_t ShardokGameEngine::timeInHashComputation_ = 0;
thread_local uint64_t ShardokGameEngine::timeInLegalActionsComputation_ = 0;
ShardokGameEngine::ShardokGameEngine(
[[maybe_unused]] const ShardokEngine* engine,
@@ -62,14 +58,49 @@ ShardokGameEngine::ShardokGameEngine(
std::unique_ptr<MCTSGameState> ShardokGameEngine::applyAction(
const MCTSGameState& state,
const MCTSAction& action,
double deterministicRoll) const {
const MCTSAction& action) const {
const auto* shardokState = dynamic_cast<const ShardokGameState*>(&state);
const auto* shardokAction = dynamic_cast<const ShardokAction*>(&action);
if (!shardokState || !shardokAction) { return nullptr; }
const auto currentPlayer = static_cast<PlayerId>(state.currentPlayerId());
const uint64_t currentStateHash = shardokState->hash();
const size_t actionIndex = shardokAction->getIndex();
const int roll = 50; // Fixed roll for deterministic transitions
// Check transition cache: have we applied this (action, roll) to this state before?
TransitionKey transitionKey{actionIndex, roll};
if (auto it = legalActionsCache_.find(currentStateHash); it != legalActionsCache_.end()) {
if (auto transIt = it->second.actionResults.find(transitionKey);
transIt != it->second.actionResults.end()) {
// We've applied this transition before - get the next state hash
uint64_t nextStateHash = transIt->second;
// Look up the next state in the cache
if (auto nextIt = legalActionsCache_.find(nextStateHash);
nextIt != legalActionsCache_.end()) {
// TRANSITION CACHE HIT: We have the complete next state cached!
transitionCacheHits_++;
// Clone the cached engine and return the state
auto engine = std::make_shared<ShardokEngine>(*nextIt->second.engine);
return std::make_unique<ShardokGameState>(
engine->GetCurrentGameState(),
scoreCalculator_,
gameSettings_.get(),
isDefender_,
strategy_,
castleCoords_,
*apdCache_,
*alCache_,
criticalTileCoords_);
}
}
}
// TRANSITION CACHE MISS: Apply action normally
transitionCacheMisses_++;
// Use cached engine if available (avoids recomputing GetAvailableCommands for same state)
std::shared_ptr<ShardokEngine> engine;
@@ -93,54 +124,9 @@ std::unique_ptr<MCTSGameState> ShardokGameEngine::applyAction(
engine = std::make_shared<ShardokEngine>(*engine);
}
// Create deterministic random generator if a specific roll is requested
// deterministicRoll of -1.0 (default) means use random generator
// Any other value (including negative) creates a deterministic generator
// For open-ended percentile commands, we compute a sequence of values that will
// produce the desired final result through the normal open-ended mechanics
std::shared_ptr<::RandomGenerator> randomGen = nullptr;
constexpr double kNoRollSentinel = -1.0;
if (deterministicRoll != kNoRollSentinel) {
std::vector<double> sequence;
engine->PostCommand(currentPlayer, actionIndex, kAverageGenerator);
if (deterministicRoll >= 5.0 && deterministicRoll <= 95.0) {
// Normal range: single value works directly
sequence = {deterministicRoll / 100.0};
} else if (deterministicRoll < 5.0) {
// Need open-ended LOW result (e.g., -100 for guaranteed success)
// OpenEndedPercentile: if initial < 5, returns initial - OpenEndedHighImpl(0, 4)
// We want: initial - accumulated = deterministicRoll
// Use initial = 2 (clearly < 5), so accumulated = 2 - deterministicRoll
constexpr double kInitialLow = 2.0;
sequence = {kInitialLow / 100.0};
// OpenEndedHighImpl accumulates rolls until one < 95
// Split accumulated into rolls: 96 (continues) + remaining (stops)
double remaining = kInitialLow - deterministicRoll;
while (remaining > 95.0) {
sequence.push_back(0.96); // 96 > 95, continues accumulation
remaining -= 96.0;
}
sequence.push_back(remaining / 100.0); // Final roll < 95, stops
} else {
// Need open-ended HIGH result (e.g., 150 for guaranteed failure)
// OpenEndedPercentile: if initial > 95, returns OpenEndedHighImpl(initial, 4)
// OpenEndedHighImpl accumulates rolls until one < 95
constexpr double kInitialHigh = 96.0;
sequence = {kInitialHigh / 100.0};
double remaining = deterministicRoll - kInitialHigh;
while (remaining > 95.0) {
sequence.push_back(0.96);
remaining -= 96.0;
}
sequence.push_back(remaining / 100.0);
}
randomGen = std::make_shared<::SequenceRandomGenerator>(sequence);
}
engine->PostCommand(currentPlayer, shardokAction->getIndex(), randomGen);
// Create and return the new state
// Create and return the new state (don't cache the mutated engine)
auto newState = std::make_unique<ShardokGameState>(
engine->GetCurrentGameState(),
scoreCalculator_,
@@ -152,10 +138,14 @@ std::unique_ptr<MCTSGameState> ShardokGameEngine::applyAction(
*alCache_,
criticalTileCoords_);
// Cache the engine on the new state so score() can use it for END_TURN normalization
// The engine's command list may be stale after the action was applied, but that's OK -
// we'll refresh it when we call GetAvailableCommandsForAIPlayer() in score()
newState->setCachedEngine(engine);
// Store the transition in cache for future lookups
const uint64_t nextStateHash = newState->hash();
legalActionsCache_[currentStateHash].actionResults[transitionKey] = nextStateHash;
// CRITICAL: Also store the next state's engine in the cache
// Without this, transition cache lookups will always miss because the next state won't exist
// Clone the engine so we have an independent cached copy
legalActionsCache_[nextStateHash].engine = std::make_shared<ShardokEngine>(*engine);
// Don't pre-compute hash - let it be computed lazily on first use
// Many states (especially in simulation) never need their hash computed
@@ -175,6 +165,36 @@ void ShardokGameEngine::applyActionMutable(
}
const auto currentPlayer = static_cast<PlayerId>(state->currentPlayerId());
const uint64_t currentStateHash = shardokState->hash();
const size_t actionIndex = shardokAction->getIndex();
const int roll = 50; // Fixed roll for deterministic transitions
// Check transition cache: have we applied this (action, roll) to this state before?
TransitionKey transitionKey{actionIndex, roll};
if (auto it = legalActionsCache_.find(currentStateHash); it != legalActionsCache_.end()) {
if (auto transIt = it->second.actionResults.find(transitionKey);
transIt != it->second.actionResults.end()) {
// We've applied this transition before - get the next state hash
uint64_t nextStateHash = transIt->second;
// Look up the next state in the cache
if (auto nextIt = legalActionsCache_.find(nextStateHash);
nextIt != legalActionsCache_.end()) {
// TRANSITION CACHE HIT: We have the complete next state cached!
transitionCacheHits_++;
// Clone the cached engine and update the current state to match
auto engine = std::make_shared<ShardokEngine>(*nextIt->second.engine);
shardokState->getMutableShardokState() = engine->GetCurrentGameState();
shardokState->setCachedEngine(engine);
shardokState->invalidateHashCache();
return;
}
}
}
// TRANSITION CACHE MISS: Apply action normally
transitionCacheMisses_++;
// Use cached engine if available
std::shared_ptr<ShardokEngine> engine;
@@ -194,11 +214,20 @@ void ShardokGameEngine::applyActionMutable(
engine = std::make_shared<ShardokEngine>(*engine);
}
engine->PostCommand(currentPlayer, shardokAction->getIndex(), nullptr);
engine->PostCommand(currentPlayer, actionIndex, kAverageGenerator);
shardokState->getMutableShardokState() = engine->GetCurrentGameState();
// Clear the cached engine and hash since the state has been mutated
shardokState->setCachedEngine(nullptr);
shardokState->invalidateHashCache();
// Store the transition in cache for future lookups
const uint64_t nextStateHash = shardokState->hash();
legalActionsCache_[currentStateHash].actionResults[transitionKey] = nextStateHash;
// CRITICAL: Also store the next state's engine in the cache
// Without this, transition cache lookups will always miss because the next state won't exist
// Clone the engine so we have an independent cached copy
legalActionsCache_[nextStateHash].engine = std::make_shared<ShardokEngine>(*engine);
}
std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
@@ -224,61 +253,47 @@ std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
const auto hashStart = std::chrono::high_resolution_clock::now();
const uint64_t stateHash = shardokState->hash();
const auto hashEnd = std::chrono::high_resolution_clock::now();
timeInHashComputation_.fetch_add(
std::chrono::duration_cast<std::chrono::microseconds>(hashEnd - hashStart).count(),
std::memory_order_relaxed);
timeInHashComputation_ +=
std::chrono::duration_cast<std::chrono::microseconds>(hashEnd - hashStart).count();
// Check transposition table for cached legal actions
if (auto it = legalActionsCache_.find(stateHash); it != legalActionsCache_.end()) {
cacheHits_.fetch_add(1, std::memory_order_relaxed);
cacheHits_++;
// Use cached engine
shardokState->setCachedEngine(it->second.engine);
// Get commands from the cached engine (Engine already caches these internally)
// If we have cached actions, clone and return them (fastest path)
if (!it->second.cachedActions.empty()) {
std::vector<std::unique_ptr<MCTSAction>> actions;
actions.reserve(it->second.cachedActions.size());
for (const auto& action : it->second.cachedActions) {
actions.push_back(action->clone());
}
return actions;
}
// Fallback: reconstruct actions from cached engine (shouldn't happen often)
const CommandListSPtr commands =
it->second.engine->GetAvailableCommandsForAIPlayer(currentPlayer);
if (!commands || commands->empty()) { return {}; }
// Convert to MCTSActions using stored filtered indices
std::vector<std::unique_ptr<MCTSAction>> actions;
actions.reserve(it->second.filteredIndices.size());
for (const size_t origIdx : it->second.filteredIndices) {
if (origIdx < commands->size()) {
const auto& cmd = commands->at(origIdx);
// Extract essential fields directly from command (no proto conversion!)
actions.push_back(std::make_unique<ShardokAction>(
origIdx,
cmd->GetCommandType(),
cmd->GetPlayerId(),
cmd->GetActorUnitId(),
cmd->GetTargetRow(),
cmd->GetTargetColumn(),
cmd->HasOdds()));
// Use new constructor: pass command pointer instead of converting to proto
actions.push_back(std::make_unique<ShardokAction>(cmd.get(), origIdx));
}
}
// Sort actions by weight (descending) to ensure MCTS explores high-value actions first
const std::vector<double> weights = getActionWeights(actions, state);
std::vector<size_t> sortedIndices(actions.size());
std::iota(sortedIndices.begin(), sortedIndices.end(), 0);
std::sort(sortedIndices.begin(), sortedIndices.end(), [&weights](size_t a, size_t b) {
return weights[a] > weights[b];
});
std::vector<std::unique_ptr<MCTSAction>> sortedActions;
sortedActions.reserve(actions.size());
for (size_t idx : sortedIndices) { sortedActions.push_back(std::move(actions[idx])); }
return sortedActions;
return actions;
}
cacheMisses_.fetch_add(1, std::memory_order_relaxed);
cacheMisses_++;
// Time legal actions computation
const auto actionsStart = std::chrono::high_resolution_clock::now();
@@ -312,66 +327,30 @@ std::vector<std::unique_ptr<MCTSAction>> ShardokGameEngine::getLegalActions(
});
// Convert only filtered commands to MCTSActions
// Use new constructor: pass command pointer instead of converting to proto (major speedup!)
std::vector<std::unique_ptr<MCTSAction>> actions;
actions.reserve(filteredIndices.size());
for (const size_t idx : filteredIndices) {
if (idx < commands->size()) {
const auto& cmd = commands->at(idx);
// Extract essential fields directly from command (no proto conversion!)
actions.push_back(std::make_unique<ShardokAction>(
idx,
cmd->GetCommandType(),
cmd->GetPlayerId(),
cmd->GetActorUnitId(),
cmd->GetTargetRow(),
cmd->GetTargetColumn(),
cmd->HasOdds()));
actions.push_back(std::make_unique<ShardokAction>(cmd.get(), idx));
}
}
// Sort actions by weight (descending) to ensure MCTS explores high-value actions first
// This is critical when maxPlayerFlips is low (e.g., 1), as only the first few actions
// get explored deeply. Original indices are preserved in ShardokAction::getIndex()
const std::vector<double> weights = getActionWeights(actions, state);
// Create index vector for sorting
std::vector<size_t> sortedIndices(actions.size());
std::iota(sortedIndices.begin(), sortedIndices.end(), 0);
// Sort indices by weight (descending)
std::sort(sortedIndices.begin(), sortedIndices.end(), [&weights](size_t a, size_t b) {
return weights[a] > weights[b];
});
// Reorder actions according to sorted indices
std::vector<std::unique_ptr<MCTSAction>> sortedActions;
sortedActions.reserve(actions.size());
for (size_t idx : sortedIndices) { sortedActions.push_back(std::move(actions[idx])); }
actions = std::move(sortedActions);
const auto actionsEnd = std::chrono::high_resolution_clock::now();
timeInLegalActionsComputation_.fetch_add(
timeInLegalActionsComputation_ +=
std::chrono::duration_cast<std::chrono::microseconds>(actionsEnd - actionsStart)
.count(),
std::memory_order_relaxed);
.count();
// Store in transposition table for future lookups
// Note: We only store filtered indices and the engine (which caches commands internally)
// This avoids duplicating heavy protocol buffer objects
// Use lazy_emplace_l to ensure thread-safe insertion (locks the bucket during construction)
legalActionsCache_.lazy_emplace_l(
stateHash,
[&](typename decltype(legalActionsCache_)::value_type& v) {
// Update existing entry
v.second.filteredIndices = filteredIndices;
v.second.engine = engine;
},
[&](const typename decltype(legalActionsCache_)::constructor& ctor) {
// Create new entry
ctor(stateHash, LegalActionsCache{filteredIndices, engine});
});
LegalActionsCache entry;
entry.filteredIndices = filteredIndices;
entry.engine = engine;
// Clone actions to cache them (allows fast retrieval without reconstruction)
entry.cachedActions.reserve(actions.size());
for (const auto& action : actions) { entry.cachedActions.push_back(action->clone()); }
legalActionsCache_[stateHash] = std::move(entry);
return actions;
}
@@ -404,28 +383,17 @@ std::vector<double> ShardokGameEngine::getActionWeights(
"indicates a type mismatch in the MCTS adapter layer");
}
// Get cached engine and command list for looking up command protos
auto cachedEngine = shardokState->getCachedEngine();
if (!cachedEngine) {
throw MCTSInternalError(
"ShardokGameEngine::getActionWeights called with state that has no cached engine");
}
const auto currentPlayer = static_cast<PlayerId>(state.currentPlayerId());
const CommandListSPtr commands = cachedEngine->GetAvailableCommandsForAIPlayer(currentPlayer);
// Determine if current player is defender (not root player!)
// During simulation we need to use the correct perspective for action weighting
bool currentPlayerIsDefender = false;
const auto& gameState = shardokState->getShardokState();
for (const auto* pi : *gameState->player_infos()) {
if (pi->player_id() == currentPlayer) {
currentPlayerIsDefender = pi->is_defender();
break;
// Check cache for action weights
const uint64_t stateHash = shardokState->hash();
if (auto it = legalActionsCache_.find(stateHash); it != legalActionsCache_.end()) {
if (!it->second.actionWeights.empty() &&
it->second.actionWeights.size() == actions.size()) {
// Cache hit! Return cached weights
return it->second.actionWeights;
}
}
// Use AIHeuristicWeighting for fast O(1) context-aware command weighting
// Cache miss: compute action weights using AIHeuristicWeighting
std::vector<double> weights;
weights.reserve(actions.size());
@@ -436,30 +404,20 @@ std::vector<double> ShardokGameEngine::getActionWeights(
"ShardokGameEngine::getActionWeights encountered non-Shardok action - this "
"indicates a type mismatch in the MCTS adapter layer");
}
// Look up command proto from cached engine using action's index
const size_t cmdIndex = shardokAction->getIndex();
if (cmdIndex >= commands->size()) {
throw MCTSInternalError(
"ShardokGameEngine::getActionWeights: action index out of bounds");
}
const auto& cmd = commands->at(cmdIndex);
weights.push_back(AIHeuristicWeighting::GetCommandWeight(
cmd->GetCommandType(),
cmd->GetActorUnitId(),
cmd->GetPlayerId(),
Coords{cmd->GetTargetRow(), cmd->GetTargetColumn()},
gameState,
shardokAction->getCommand(),
shardokState->getShardokState(),
castleCoords_,
apdCache_,
currentPlayerIsDefender, // Use current player's role, not root player's!
isDefender_,
[this](BattalionTypeId typeId) {
return gameSettings_->GetGetter().GetBattalionType(typeId);
}));
}
// Store weights in cache for future lookups
legalActionsCache_[stateHash].actionWeights = weights;
return weights;
}
@@ -469,7 +427,6 @@ double ShardokGameEngine::getActionScore(
MCTSPlayerId playerId) const {
auto newState = applyAction(state, action);
if (!newState) { return 0.0; }
return newState->score(playerId);
}
@@ -499,34 +456,31 @@ size_t ShardokGameEngine::mapFilteredIndexToOriginal(
}
void ShardokGameEngine::reportCacheStatistics() const {
const uint64_t hits = cacheHits_.load(std::memory_order_relaxed);
const uint64_t misses = cacheMisses_.load(std::memory_order_relaxed);
const uint64_t hashTime = timeInHashComputation_.load(std::memory_order_relaxed);
const uint64_t actionsTime = timeInLegalActionsComputation_.load(std::memory_order_relaxed);
const uint64_t totalLookups = hits + misses;
const uint64_t totalLookups = cacheHits_ + cacheMisses_;
if (totalLookups > 0) {
const double hitRate = static_cast<double>(hits) / static_cast<double>(totalLookups);
const double hitRate = static_cast<double>(cacheHits_) / static_cast<double>(totalLookups);
const double avgHashTimeUs =
static_cast<double>(hashTime) / static_cast<double>(totalLookups);
static_cast<double>(timeInHashComputation_) / static_cast<double>(totalLookups);
const double avgActionsTimeUs =
misses > 0 ? static_cast<double>(actionsTime) / static_cast<double>(misses) : 0.0;
cacheMisses_ > 0 ? static_cast<double>(timeInLegalActionsComputation_) /
static_cast<double>(cacheMisses_)
: 0.0;
printf("Legal Actions Cache Stats:\n");
printf(" Lookups: %llu hits, %llu misses, %.1f%% hit rate, %zu entries\n",
static_cast<unsigned long long>(hits),
static_cast<unsigned long long>(misses),
static_cast<unsigned long long>(cacheHits_),
static_cast<unsigned long long>(cacheMisses_),
hitRate * 100.0,
legalActionsCache_.size());
printf(" Timing: %.2f us avg hash, %.2f us avg actions (on miss)\n",
avgHashTimeUs,
avgActionsTimeUs);
printf(" Total time: %.2f ms in hash, %.2f ms in actions\n",
hashTime / 1000.0,
actionsTime / 1000.0);
timeInHashComputation_ / 1000.0,
timeInLegalActionsComputation_ / 1000.0);
// Calculate if transposition table is worth it
const double timeWithCache = hashTime + actionsTime;
const double timeWithCache = timeInHashComputation_ + timeInLegalActionsComputation_;
const double timeWithoutCache =
avgActionsTimeUs * static_cast<double>(totalLookups); // All lookups recompute
const double savings = (timeWithoutCache - timeWithCache) / timeWithoutCache * 100.0;
@@ -534,95 +488,39 @@ void ShardokGameEngine::reportCacheStatistics() const {
savings,
(timeWithoutCache - timeWithCache) / 1000.0);
}
// Report transition cache statistics
const uint64_t totalTransitions = transitionCacheHits_ + transitionCacheMisses_;
if (totalTransitions > 0) {
const double transitionHitRate =
static_cast<double>(transitionCacheHits_) / static_cast<double>(totalTransitions);
printf("\nTransition Cache Stats:\n");
printf(" Transitions: %llu hits, %llu misses, %.1f%% hit rate\n",
static_cast<unsigned long long>(transitionCacheHits_),
static_cast<unsigned long long>(transitionCacheMisses_),
transitionHitRate * 100.0);
// Calculate total number of transition entries across all states
size_t totalTransitionEntries = 0;
for (const auto& [stateHash, cache] : legalActionsCache_) {
totalTransitionEntries += cache.actionResults.size();
}
printf(" Total transition entries: %zu (avg %.1f per state)\n",
totalTransitionEntries,
totalTransitionEntries > 0 ? static_cast<double>(totalTransitionEntries) /
static_cast<double>(legalActionsCache_.size())
: 0.0);
}
}
void ShardokGameEngine::resetCacheStatistics() {
cacheHits_.store(0, std::memory_order_relaxed);
cacheMisses_.store(0, std::memory_order_relaxed);
timeInHashComputation_.store(0, std::memory_order_relaxed);
timeInLegalActionsComputation_.store(0, std::memory_order_relaxed);
cacheHits_ = 0;
cacheMisses_ = 0;
timeInHashComputation_ = 0;
timeInLegalActionsComputation_ = 0;
transitionCacheHits_ = 0;
transitionCacheMisses_ = 0;
}
ChanceOutcomeInfo ShardokGameEngine::getBinaryOutcomeInfo(
const MCTSGameState& state,
const MCTSAction& action) const {
const auto* shardokState = dynamic_cast<const ShardokGameState*>(&state);
const auto* shardokAction = dynamic_cast<const ShardokAction*>(&action);
if (!shardokState || !shardokAction) {
throw ShardokInternalErrorException("Invalid state or action type in getBinaryOutcomeInfo");
}
// Check for multi-outcome commands (roll affects outcome quality, not just success/failure)
// These use multiOutcome() with fixed seeds to sample the range of possible results
using namespace net::eagle0::shardok::common;
const auto commandType = static_cast<CommandType>(shardokAction->getType());
switch (commandType) {
case END_TURN_COMMAND:
// END_TURN has random effects (fire spread, weather changes)
return ChanceOutcomeInfo::multiOutcome(5);
case MELEE_COMMAND:
case ARCHERY_COMMAND:
case CHARGE_COMMAND:
case REDUCE_COMMAND:
// Combat/siege commands: OpenEndedPercentile roll affects damage dealt
// Use 5 outcomes to sample the roll distribution
return ChanceOutcomeInfo::multiOutcome(5);
case CHALLENGE_DUEL_COMMAND:
// Duels have multiple combat rounds with rolls, so outcomes vary significantly
return ChanceOutcomeInfo::multiOutcome(5);
default:
// Continue to binary outcome handling below
break;
}
const auto currentPlayer = static_cast<PlayerId>(state.currentPlayerId());
// Get or create the engine for this state
std::shared_ptr<ShardokEngine> engine;
if (auto cachedEngine = shardokState->getCachedEngine()) {
engine = cachedEngine;
} else {
engine = std::make_shared<ShardokEngine>(
gameSettings_,
shardokState->getShardokState(),
criticalTileCoords_,
0,
false);
// Populate command cache
[[maybe_unused]] const auto commands =
engine->GetAvailableCommandsForAIPlayer(currentPlayer);
shardokState->setCachedEngine(engine);
}
// Get command descriptors
const auto descriptors = engine->GetAvailableCommandsForAIPlayer(currentPlayer);
const size_t actionIndex = shardokAction->getIndex();
if (actionIndex >= descriptors->size()) {
throw ShardokInternalErrorException("Action index out of range in getBinaryOutcomeInfo");
}
const auto& descriptor = descriptors->at(actionIndex);
// Get success probability for binary outcome actions
if (!descriptor->HasOdds()) {
throw ShardokInternalErrorException("Action does not have odds in getBinaryOutcomeInfo");
}
const auto successChancePercentile = descriptor->GetOddsPercentile();
const double successProbability = static_cast<double>(successChancePercentile) / 100.0;
return ChanceOutcomeInfo::binary(successProbability);
}
void ShardokGameEngine::clearLegalActionsCache() { legalActionsCache_.clear(); }
// Extern-linkage function for testing
void clearLegalActionsCache_ForTesting() { ShardokGameEngine::clearLegalActionsCache(); }
} // namespace shardok::mcts
@@ -14,6 +14,7 @@
#include "src/main/cpp/net/eagle0/common/mcts/abstract/MCTSGameEngine.hpp"
#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/map/CoordsSet.hpp"
@@ -47,8 +48,7 @@ public:
// MCTSGameEngine interface implementation
[[nodiscard]] std::unique_ptr<MCTSGameState> applyAction(
const MCTSGameState& state,
const MCTSAction& action,
double deterministicRoll = -1.0) const override;
const MCTSAction& action) const override;
void applyActionMutable(std::unique_ptr<MCTSGameState>& state, const MCTSAction& action)
const override;
@@ -86,10 +86,6 @@ public:
size_t filteredIndex,
const MCTSGameState& state) const override;
[[nodiscard]] BinaryOutcomeInfo getBinaryOutcomeInfo(
const MCTSGameState& state,
const MCTSAction& action) const override;
// Report transposition table statistics
void reportCacheStatistics() const;
@@ -97,11 +93,34 @@ public:
void resetCacheStatistics();
private:
// Key for transition cache: (actionIndex, roll)
// Caches state transitions to avoid redundant PostCommand calls
struct TransitionKey {
size_t actionIndex;
int roll;
bool operator==(const TransitionKey& other) const {
return actionIndex == other.actionIndex && roll == other.roll;
}
};
// Hash function for TransitionKey
struct TransitionKeyHash {
size_t operator()(const TransitionKey& key) const {
return std::hash<size_t>{}(key.actionIndex) ^ (std::hash<int>{}(key.roll) << 1);
}
};
// Transposition table entry for caching legal actions
// Note: We don't store command protos since the Engine already caches them
struct LegalActionsCache {
std::vector<size_t> filteredIndices;
std::shared_ptr<ShardokEngine> engine; // Engine with populated command cache
// NEW: Cache state transitions (action, roll) -> nextStateHash
gtl::flat_hash_map<TransitionKey, uint64_t, TransitionKeyHash> actionResults;
// NEW: Cache action weights to avoid recomputing heuristics
std::vector<double> actionWeights;
// NEW: Cache the actual action objects to avoid repeated allocation/construction
std::vector<std::unique_ptr<MCTSAction>> cachedActions;
};
const AIScoreCalculator* scoreCalculator_;
@@ -110,32 +129,23 @@ private:
const ALCache* alCache_;
bool isDefender_;
AIStrategy strategy_;
const CoordsSet castleCoords_; // Own the data to avoid dangling references
const CoordsSet& castleCoords_;
// Computed once to avoid 8.5% overhead per engine construction
const CoordsSet criticalTileCoords_; // Own the data to avoid dangling references
const CoordsSet& criticalTileCoords_;
// Transposition table for legal actions (shared across threads with lock-free hash map)
// parallel_flat_hash_map provides thread-safe concurrent access without explicit locking
// Using 8 submaps (N=8) to reduce contention with default 16 MCTS threads
static gtl::parallel_flat_hash_map<
uint64_t,
LegalActionsCache,
std::hash<uint64_t>,
std::equal_to<uint64_t>,
std::allocator<std::pair<const uint64_t, LegalActionsCache>>,
8,
std::mutex>
legalActionsCache_;
static std::atomic<uint64_t> cacheHits_;
static std::atomic<uint64_t> cacheMisses_;
// Transposition table for legal actions (thread-local to avoid lock contention)
// Each thread maintains its own cache during multithreaded simulation
static thread_local gtl::flat_hash_map<uint64_t, LegalActionsCache> legalActionsCache_;
static thread_local uint64_t cacheHits_;
static thread_local uint64_t cacheMisses_;
// Transition cache statistics
static thread_local uint64_t transitionCacheHits_;
static thread_local uint64_t transitionCacheMisses_;
// Performance timing (in microseconds)
static std::atomic<uint64_t> timeInHashComputation_;
static std::atomic<uint64_t> timeInLegalActionsComputation_;
public:
// Clear the static legal actions cache (useful for tests)
static void clearLegalActionsCache();
static thread_local uint64_t timeInHashComputation_;
static thread_local uint64_t timeInLegalActionsComputation_;
};
} // namespace mcts
@@ -41,32 +41,10 @@ uint64_t ShardokGameState::hash() const {
return cachedHash_;
}
double ShardokGameState::score(MCTSPlayerId playerId) const {
// Honor the interface contract: score() should return evaluation from playerId's perspective.
// Map the requested playerId to defender/attacker role to determine scoring perspective.
// Look up which player ID is the defender from game state
bool foundDefender = false;
bool requestedPlayerIsDefender = false;
if (state_->player_infos()) {
for (const auto* pi : *state_->player_infos()) {
if (pi && pi->is_defender()) {
foundDefender = true;
requestedPlayerIsDefender = (static_cast<PlayerId>(playerId) == pi->player_id());
break;
}
}
}
// Fallback: if we can't determine from game state, use isDefender_ which represents
// the root player's role (and playerId is always the root player in practice)
const bool scoreFromDefenderPerspective =
foundDefender ? requestedPlayerIsDefender : isDefender_;
// Score the current state directly
return scoreCalculator_
->GuessedStateScore(scoreFromDefenderPerspective, state_, strategy_, castleCoords_);
double ShardokGameState::score(MCTSPlayerId /*playerId*/) const {
// Always return score from the perspective of the AI that created this state (isDefender_).
// The playerId parameter is ignored - adversarial logic happens in selection, not scoring.
return scoreCalculator_->GuessedStateScore(isDefender_, state_, strategy_, castleCoords_);
}
MCTSPlayerId ShardokGameState::currentPlayerId() const { return state_->current_player(); }
@@ -68,12 +68,12 @@ private:
const GameSettings* settings_;
bool isDefender_;
AIStrategy strategy_;
const CoordsSet castleCoords_; // Own the data to avoid dangling references
const CoordsSet& castleCoords_;
const APDCache& apdCache_;
const ALCache& alCache_;
mutable uint64_t cachedHash_ = 0;
mutable bool hashCached_ = false;
const CoordsSet criticalTileCoords_; // Own the data to avoid dangling references
const CoordsSet& criticalTileCoords_;
mutable std::shared_ptr<ShardokEngine> cachedEngine_; // Engine with cached available commands
};
@@ -56,6 +56,18 @@ std::unique_ptr<MCTSGameState> ShardokMCTSFactory::createGameState(
criticalTileCoords);
}
std::vector<std::unique_ptr<MCTSAction>> ShardokMCTSFactory::createActions(
const std::vector<CommandProto>& commands) {
std::vector<std::unique_ptr<MCTSAction>> actions;
actions.reserve(commands.size());
for (size_t i = 0; i < commands.size(); ++i) {
actions.push_back(std::make_unique<ShardokAction>(commands[i], i));
}
return actions;
}
std::vector<std::unique_ptr<MCTSAction>> ShardokMCTSFactory::createActionsFromCommandList(
const CommandListSPtr& commands) {
std::vector<std::unique_ptr<MCTSAction>> actions;
@@ -64,16 +76,7 @@ std::vector<std::unique_ptr<MCTSAction>> ShardokMCTSFactory::createActionsFromCo
actions.reserve(commands->size());
for (size_t i = 0; i < commands->size(); ++i) {
const auto& cmd = (*commands)[i];
// Extract essential fields directly from command (no proto conversion!)
actions.push_back(std::make_unique<ShardokAction>(
i,
cmd->GetCommandType(),
cmd->GetPlayerId(),
cmd->GetActorUnitId(),
cmd->GetTargetRow(),
cmd->GetTargetColumn(),
cmd->HasOdds()));
actions.push_back(std::make_unique<ShardokAction>(cmd->GetCommandProto(), i));
}
return actions;
@@ -16,6 +16,7 @@
#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/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
namespace shardok {
@@ -26,6 +27,14 @@ class AIScoreCalculator;
class GameStateW;
class GameSettings;
// Use existing type definitions to avoid conflicts
// These are already defined in the Shardok codebase:
// - APDCache in ActionPointDistancesCache.hpp
// - ALCache in AIAttackLocations.hpp
// - CommandListSPtr in ShardokCommand.hpp
// - SettingsGetter in GameSettings.hpp
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
namespace mcts {
// Forward declarations
@@ -35,6 +44,8 @@ class MCTSAction;
class ShardokMCTSFactory {
public:
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
// Create a Shardok game engine adapter
[[nodiscard]] static std::unique_ptr<MCTSGameEngine> createGameEngine(
const ShardokEngine& engine,
@@ -59,6 +70,10 @@ public:
const ALCache& alCache,
const CoordsSet& criticalTileCoords);
// Convert Shardok commands to MCTS actions
[[nodiscard]] static std::vector<std::unique_ptr<MCTSAction>> createActions(
const std::vector<CommandProto>& commands);
// Convert from command list to MCTS actions
[[nodiscard]] static std::vector<std::unique_ptr<MCTSAction>> createActionsFromCommandList(
const CommandListSPtr& commands);
@@ -12,6 +12,7 @@
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCTypes.h"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/ActionPointDistancesCache.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/command_descriptor.pb.h"
namespace shardok {
@@ -20,6 +21,7 @@ using std::future;
using std::vector;
using ScoreValue = double;
using CommandProto = net::eagle0::shardok::api::CommandDescriptor;
// Forward declarations
class ShardokEngine;
@@ -15,6 +15,7 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/library:shardok_c_types",
"//src/main/cpp/net/eagle0/shardok/library/action_point_distances:action_point_distances_cache",
"//src/main/cpp/net/eagle0/shardok/library/map:coords_set",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_cc_proto",
],
)
@@ -41,14 +41,13 @@ namespace {
// A 100% unit advantage (all attacker, no defender) produces ±80 score
constexpr double UNITS_SCORE_SCALE = 80.0;
// Normalizer for victory condition scores (also proportional to army size)
// Typical victory condition range: -3000 to 0 (raw), becomes approximately -30 to 0 (normalized)
constexpr double VICTORY_SCORE_SCALE = 400.0;
// Minimum reference value to avoid division by zero in edge cases
constexpr double MIN_REFERENCE_VALUE = 1000.0;
// IMPORTANT: Victory condition scores are NOT normalized by army size
// They represent absolute strategic goals (castle control, etc.) that should not
// diminish as more units are placed. Typical range: -3000 to +3000 (raw).
// Scaling factor of 0.01 brings them to -30 to +30 range.
} // anonymous namespace
/// MCTS-Optimized implementation of AIScoreCalculator.
@@ -146,9 +145,8 @@ auto MCTSOptimizedAIScoreCalculator::CombineAttackerScores(
const double unitsDiff = components.attackerUnitsValue - components.defenderUnitsValue;
const double unitsScore = (unitsDiff / reference) * UNITS_SCORE_SCALE;
// Victory condition score is an absolute strategic value, not normalized by army size
// Scaling factor to bring victory scores into similar magnitude as unit scores
const double victoryScore = victoryConditionScore * 0.01;
// Normalize victory condition (also proportional to army size) to approximately [-40, 0] range
const double victoryScore = (victoryConditionScore / reference) * VICTORY_SCORE_SCALE;
// Weight units by rounds remaining (early: units matter less, late: units dominate)
const double unitsMultiplier =
@@ -179,10 +177,7 @@ auto MCTSOptimizedAIScoreCalculator::CombineDefenderHoldCastlesScores(
// Similar to attacker, but from defender's perspective
const double unitsDiff = components.defenderUnitsValue - components.attackerUnitsValue;
const double unitsScore = (unitsDiff / reference) * UNITS_SCORE_SCALE;
// Victory condition score is an absolute strategic value, not normalized by army size
// Scaling factor to bring victory scores into similar magnitude as unit scores
const double victoryScore = victoryConditionScore * 0.01;
const double victoryScore = (victoryConditionScore / reference) * VICTORY_SCORE_SCALE;
const double unitsMultiplier =
static_cast<double>(roundsRemaining) / static_cast<double>(GetMaxRounds());
@@ -206,19 +206,11 @@ auto StandardAIScoreCalculator::CombineDefenderHoldCastlesScores(
const UnitsScoreComponents &components,
const double victoryConditionScore,
const int roundsRemaining) const -> ScoreValue {
(void)roundsRemaining; // Intentionally unused for now
// From defender's perspective: negate the attacker-defender difference
const double unitsDifference = components.defenderUnitsValue - components.attackerUnitsValue;
// TODO: The time-decay multiplier (roundsRemaining/maxRounds) was causing END_TURN
// to score better than tactical actions because it reduced the penalty for having
// fewer units. Setting to constant 1.0 for now to fix tactical decision-making.
const double unitsMultiplier = 1.0;
// const double unitsMultiplier =
// static_cast<double>(roundsRemaining) / static_cast<double>(GetMaxRounds());
const double finalScore =
UNITS_BASE_MULTIPLIER * unitsMultiplier * unitsDifference + victoryConditionScore;
return finalScore;
const double unitsMultiplier =
static_cast<double>(roundsRemaining) / static_cast<double>(GetMaxRounds());
return UNITS_BASE_MULTIPLIER * unitsMultiplier * unitsDifference + victoryConditionScore;
}
auto StandardAIScoreCalculator::DefenderFleeStrategyScoreForState(const GameStateW &gameState) const
@@ -7,13 +7,12 @@
#include <iostream>
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.hpp"
#include "src/main/cpp/net/eagle0/common/TsvParser.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/AIClientFactory.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/GamePhaseRunner.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/GameSettingsFactory.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/fb_helpers/GameStateHelpers.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/cpp/net/eagle0/shardok/util/BattalionTypeRegistrar.hpp"
#include "src/main/cpp/net/eagle0/shardok/util/MapLoader.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/unit.hpp"
@@ -68,7 +67,20 @@ AiBattleSimulator::AiBattleSimulator(const BattleConfigProto& config, GameSettin
gameSettings_(std::move(gameSettings)) {
if (!gameSettings_) {
// Initialize default game settings
gameSettings_ = ai_testing_common::GameSettingsFactory::CreateDefault();
gameSettings_ = std::make_shared<GameSettings>();
auto setter = gameSettings_->GetSetter();
// Load battalion types
BattalionTypeRegistrar::RegisterBattalionTypes(setter);
// Load settings from file
const std::string settingsPath =
FilesystemUtils::StaticShardokFilesDirectory() + "settings.tsv";
const std::string settingsTsv = std::string(byte_vector::FromPath(settingsPath));
TsvParser parser;
const auto valuesAndTypes = parser.ParseColumnEntryTsv(settingsTsv);
setter.SetFromTypesAndValues(valuesAndTypes[1], valuesAndTypes[0]);
}
}
@@ -349,7 +361,7 @@ std::unique_ptr<ShardokAIClient> AiBattleSimulator::CreateAIClient(
AIAlgorithmType algorithmType = ConvertAIAlgorithmType(playerConfig.ai_algorithm());
return ai_testing_common::AIClientFactory::Create(
return std::make_unique<ShardokAIClient>(
playerId,
isDefender,
hexMap,
@@ -361,28 +373,44 @@ BattleResult AiBattleSimulator::RunSetupPhase(
ShardokEngine& engine,
ShardokAIClient& attackerAI,
ShardokAIClient& defenderAI) {
auto getAI = [&](PlayerId playerId) -> ShardokAIClient& {
return (playerId == ATTACKER_ID) ? attackerAI : defenderAI;
};
int commandsExecuted = 0;
auto phaseResult = ai_testing_common::GamePhaseRunner::RunSetupPhase(engine, getAI);
while (engine.GetCurrentGameState()->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP) {
auto currentState = engine.GetCurrentGameState();
PlayerId currentPlayer = currentState->current_player();
std::cout << "Setup phase complete. Commands executed: " << phaseResult.commandsExecuted
<< "\n";
auto availableCommands = engine.GetAvailableCommandProtos(currentPlayer, false);
// Check if game ended unexpectedly
if (phaseResult.gameEnded) {
return CreateResultFromGameState(
engine.GetCurrentGameState(),
0,
phaseResult.commandsExecuted);
if (availableCommands.empty()) {
std::cout << "No commands available during setup for player " << (int)currentPlayer
<< "\n";
break;
}
// Choose which AI to use
ShardokAIClient& activeAI = (currentPlayer == ATTACKER_ID) ? attackerAI : defenderAI;
// Get AI decision
auto choiceResults = activeAI.ChooseCommandIndex(engine);
// Apply command
engine.PostCommand(currentPlayer, choiceResults.chosenIndex);
commandsExecuted++;
// Check if game ended unexpectedly
if (engine.GameIsOver()) {
return CreateResultFromGameState(engine.GetCurrentGameState(), 0, commandsExecuted);
}
}
std::cout << "Setup phase complete. Commands executed: " << commandsExecuted << "\n";
// Return a "not finished" result
BattleResult result;
result.winner = -1;
result.totalRounds = 0;
result.totalCommands = phaseResult.commandsExecuted;
result.totalCommands = commandsExecuted;
result.endReason = BattleResult::EndReason::DRAW; // Temporary placeholder
result.description = "Setup phase completed";
return result;
@@ -23,9 +23,6 @@ cc_library(
"//src/main/cpp/net/eagle0/common:filesystem_utils",
"//src/main/cpp/net/eagle0/common:tsv_parser",
"//src/main/cpp/net/eagle0/shardok/ai:shardok_ai_client",
"//src/main/cpp/net/eagle0/shardok/ai_testing_common:ai_client_factory",
"//src/main/cpp/net/eagle0/shardok/ai_testing_common:game_phase_runner",
"//src/main/cpp/net/eagle0/shardok/ai_testing_common:game_settings_factory",
"//src/main/cpp/net/eagle0/shardok/library:engine",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library/fb_helpers:game_state_helpers",
@@ -10,6 +10,7 @@
#include "AiBattleConfig.hpp"
#include "AiBattleSimulator.hpp"
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
using shardok::ai_battle_simulator::AiBattleConfigLoader;
using shardok::ai_battle_simulator::AiBattleSimulator;
@@ -108,6 +109,10 @@ int main(int argc, char* argv[]) {
// Set exec path for FilesystemUtils
FilesystemUtils::SetExecPath(argv[0]);
// Set cache directory for ActionPointDistances
shardok::FixedActionPointDistances::SetCacheDirectory(
FilesystemUtils::CacheFilesDirectory() + "apdCache/");
try {
if (argc < 2) {
PrintUsage(argv[0]);
@@ -13,9 +13,8 @@
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/AIConfig.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/AIClientFactory.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/GamePhaseRunner.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
using namespace shardok;
@@ -83,6 +82,10 @@ int main(int argc, char* argv[]) {
// Set exec path so FilesystemUtils can find resource files
FilesystemUtils::SetExecPath(argv[0]);
// Set cache directory for ActionPointDistances
FixedActionPointDistances::SetCacheDirectory(
FilesystemUtils::CacheFilesDirectory() + "apdCache/");
try {
std::cout << "Shardok AI Performance Runner\n";
std::cout << "==============================\n";
@@ -117,7 +120,7 @@ int main(int argc, char* argv[]) {
const auto* hexMap = currentState->hex_map();
const auto settingsGetter = settings->GetGetter();
auto aiClient = ai_testing_common::AIClientFactory::Create(
ShardokAIClient aiClient(
aiPlayerId,
isDefender,
hexMap,
@@ -127,7 +130,7 @@ int main(int argc, char* argv[]) {
// Create a second AI client for the human player during setup
// This ensures consistent state handling during setup phase
const PlayerId humanPlayerId = 1;
auto humanSetupAI = ai_testing_common::AIClientFactory::Create(
ShardokAIClient humanSetupAI(
humanPlayerId,
!isDefender,
hexMap,
@@ -137,18 +140,29 @@ int main(int argc, char* argv[]) {
// Complete setup phase - AI makes intelligent placement decisions
if (currentState->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP) {
auto getAI = [&](PlayerId playerId) -> ShardokAIClient& {
return (playerId == aiPlayerId) ? *aiClient : *humanSetupAI;
};
while (currentState->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP) {
PlayerId currentPlayer = currentState->current_player();
auto availableCommands = engine.GetAvailableCommandProtos(currentPlayer, false);
auto setupResult = ai_testing_common::GamePhaseRunner::RunSetupPhase(engine, getAI);
if (availableCommands.empty()) {
std::cout << "No commands available for player "
<< static_cast<int>(currentPlayer) << "\n";
break;
}
if (setupResult.gameEnded) {
std::cout << "Game ended unexpectedly during setup phase.\n";
return 0;
if (currentPlayer == aiPlayerId) {
// Let AI make intelligent placement decisions
auto choiceResults = aiClient.ChooseCommandIndex(engine);
engine.PostCommand(currentPlayer, choiceResults.chosenIndex);
} else {
// Human player: use AI for setup to ensure consistent state handling
auto choiceResults = humanSetupAI.ChooseCommandIndex(engine);
engine.PostCommand(currentPlayer, choiceResults.chosenIndex);
}
currentState = engine.GetCurrentGameState();
}
currentState = engine.GetCurrentGameState();
}
// Test AI performance for configured number of turns
@@ -165,7 +179,7 @@ int main(int argc, char* argv[]) {
}
// Get AI decision with performance metrics
auto choiceResults = aiClient->ChooseCommandIndex(engine);
auto choiceResults = aiClient.ChooseCommandIndex(engine);
std::cout << " AI chose command index: " << choiceResults.chosenIndex << "\n";
std::cout << " Depth achieved: " << choiceResults.depthAchieved << "\n";
@@ -23,9 +23,6 @@ cc_binary(
"//src/main/cpp/net/eagle0/shardok/ai:ai_water_crossing_command_chooser",
"//src/main/cpp/net/eagle0/shardok/ai:shardok_ai_client",
"//src/main/cpp/net/eagle0/shardok/ai/score:ai_score_calculator_interface",
"//src/main/cpp/net/eagle0/shardok/ai_testing_common:ai_client_factory",
"//src/main/cpp/net/eagle0/shardok/ai_testing_common:game_phase_runner",
"//src/main/cpp/net/eagle0/shardok/ai_testing_common:game_settings_factory",
"//src/main/cpp/net/eagle0/shardok/library:engine",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
@@ -42,7 +39,6 @@ cc_library(
],
copts = COPTS,
deps = [
"//src/main/cpp/net/eagle0/shardok/ai_testing_common:game_settings_factory",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library:shardok_c_types",
"//src/main/cpp/net/eagle0/shardok/library/fb_helpers:game_state_helpers",
@@ -7,9 +7,11 @@
#include <filesystem>
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/GameSettingsFactory.hpp"
#include "src/main/cpp/net/eagle0/common/TsvParser.hpp"
#include "src/main/cpp/net/eagle0/common/byte_vector.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/fb_helpers/GameStateHelpers.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/cpp/net/eagle0/shardok/util/BattalionTypeRegistrar.hpp"
#include "src/main/cpp/net/eagle0/shardok/util/MapLoader.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/unit.hpp"
#include "src/main/protobuf/net/eagle0/shardok/common/player_info.pb.h"
@@ -28,7 +30,20 @@ constexpr PlayerId HUMAN_PLAYER_ID = 1;
} // namespace
auto PerformanceTestGameStateBuilder::InitializeGameSettings() -> GameSettingsSPtr {
return ai_testing_common::GameSettingsFactory::CreateDefault();
auto settings = std::make_shared<GameSettings>();
auto setter = settings->GetSetter();
// Load battalion types
BattalionTypeRegistrar::RegisterBattalionTypes(setter);
// Load complete settings from settings.tsv file
TsvParser parser;
const string settingsPath = FilesystemUtils::StaticShardokFilesDirectory() + "settings.tsv";
const string settingsTsv = string(byte_vector::FromPath(settingsPath));
const auto valuesAndTypes = parser.ParseColumnEntryTsv(settingsTsv);
setter.SetFromTypesAndValues(valuesAndTypes[1], valuesAndTypes[0]);
return settings;
}
auto PerformanceTestGameStateBuilder::CreatePerfTestGameState(

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