Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 4.5 f0484a74fa Add comprehensive timestamped logging for connection flow tracing
Adds a LogFlow() helper method that logs with precise timestamps in
HH:mm:ss.fff format. Uses LogFlow throughout the connection flow to
trace:
- Connection attempts and state transitions
- Subscription requests and acknowledgments
- Game updates (ActionResult, ShardokResult) with counts
- Heartbeat send/receive with sync status
- Sync mismatch detection
- Reconnect scheduling

Also removes debug Console.WriteLine statements and redundant UI
Debug.Log, replacing them with consistent LogFlow output.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 06:57:30 -08:00
adminandClaude Opus 4.5 672cb5cb20 Add diagnostic logging to ReceiveGameUpdate
Log exactly what's being received and whether updates are processed or skipped:
- ServerGameStatus updates
- Whether ActionResultViews are present
- Token comparison (incoming vs current)
- Whether the update was processed or skipped

This will help diagnose why game state isn't updating after sync mismatch reconnect.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 23:02:28 -08:00
adminandClaude Opus 4.5 816d2b6859 Add volatile to _currentState and diagnostic logging
- Make _currentState volatile to ensure visibility across threads
  (main thread reads for UI, background threads write on connect/disconnect)
- Add Debug.Log in ConnectionStatusUI when showing Reconnecting state
- Add more granular Console.WriteLine diagnostics in SendHeartbeat

This helps diagnose cases where the UI shows "Reconnecting..." but
the connection is actually Connected.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 22:46:24 -08:00
adminandClaude Opus 4.5 19b305829b Add diagnostics to distinguish ThreadPool exhaustion vs Logger blocking
Add Console.WriteLine at key points in SendHeartbeat to determine
what's actually causing the heartbeat freezes:

1. "SendHeartbeat starting" - if missing, Task.Run work never started (ThreadPool exhausted)
2. "LogLine completed" - if missing, Logger is blocking
3. "About to call WriteAsync" - timing before network I/O
4. "WriteAsync returned" - if missing, WriteAsync is blocking

This will definitively tell us whether the issue is ThreadPool
exhaustion from blocked WriteAsync or Logger blocking from slow
file I/O.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 21:54:02 -08:00
adminandClaude Opus 4.5 3c382349e6 Fix ThreadPool blocking issues causing timer callbacks to stop
This addresses the root cause of the Windows connection freeze where
heartbeat and idle timers stopped firing for ~28 seconds.

Changes:
1. Add 10-second timeout to WriteAsync calls
   - DoWithStreamingCall now uses Task.WhenAny with timeout
   - SendUpdateStreamRequestAsync uses CancellationTokenSource with timeout
   - Prevents indefinite blocking on dead connections that exhaust ThreadPool

2. Add ConfigureAwait(false) to all network operations
   - Prevents deadlocks from continuations trying to marshal back to main thread

3. Wrap timer callbacks in try-catch
   - Idle check timer: catches exceptions to prevent silent failures
   - Heartbeat timer: wraps both the Elapsed handler and SendHeartbeat task

4. Fix double PostRequest bug in TryPendingCommands
   - Removed unconditional PostRequest call at line 475 that was outside switch
   - Commands were being posted twice: once in switch case, once after

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 21:19:46 -08:00
53314e7cb6 Add Console.WriteLine for timer callbacks and make Logger thread-safe (#4857)
The timers stopped logging entirely during the 28-second gap. To determine
if the timers are firing but the Logger is blocked vs timers not firing:

1. Add Console.WriteLine in timer Elapsed handlers BEFORE the callback
   - These bypass our Logger and write directly to stdout
   - Will show if timers are firing even if Logger is blocked

2. Make Logger thread-safe with locks
   - LogLine now uses lock(_lock) to prevent concurrent access issues
   - GetLogger now uses lock(_loggersLock) for thread-safe singleton access

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 21:07:34 -08:00
d4a5f48103 Add diagnostic logging for heartbeat and idle timer issues (#4856)
Add logging to diagnose why heartbeat/idle timers stop firing on Windows:
- Log when heartbeat timer fires (before any checks)
- Log when heartbeat is skipped and why (cancellation, not connected)
- Log when heartbeat send fails (stream unavailable)
- Log idle check when idle > 5s with cancellation status

This will help diagnose the 90-second gap where no heartbeat or idle
logs appear before PROTOCOL_ERROR on Windows client.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 20:38:15 -08:00
d6d1868fcb Stop timers before sync mismatch reconnect (#4854)
When HandleHeartbeatResponse detects a sync mismatch and triggers a
reconnect, it wasn't stopping the heartbeat and idle check timers.
This could cause them to fire during reconnection, potentially
interfering with the new connection.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 20:03:39 -08:00
8c9d38c546 Fix IllegalStateException when LLM stream client disconnects (#4855)
Handle the case where a gRPC stream is completed (client disconnected) but
the LLM update consumer thread still tries to send updates. This causes
IllegalStateException: "Stream is already completed, no further calls allowed".

Added catch for IllegalStateException in both humanClientsAfterUpdatingLlmStream
and humanClientsAfterPostingResults to gracefully handle disconnected clients.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 20:01:51 -08:00
139494d937 Fix race condition between heartbeat response and game updates (#4853)
The heartbeat handler was reading game state and sending responses without
holding the GamesManager lock. Since game updates ARE sent while holding
this lock, there was a race condition where:

1. AI processing holds GamesManager lock
2. Updates start being sent to clients via the stream
3. Heartbeat request arrives, acquires EagleServiceImpl lock (different lock)
4. Heartbeat reads game count and sends response (can interleave with updates)
5. Client receives heartbeat before some in-flight updates
6. Client detects sync mismatch despite updates being in transit

This particularly affected slower/remote connections (e.g., Windows clients)
because the timing window for the race was longer.

Fix: Wrap heartbeat handling in gamesManager.synchronized to ensure the
response is sent only after any in-progress game updates have been queued.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 19:33:10 -08:00
5c52dd9006 Add HTTP/2 timeout settings and upgrade YetAnotherHttpHandler to 1.11.4 (#4851)
Upgrade YetAnotherHttpHandler from 1.5.3 to 1.11.4 to get:
- ConnectTimeout support (added in 1.8.0)
- Fix deadlock during cancellation (1.11.4)
- Memory management improvements (1.11.1)
- Backpressure control (1.10.0)

Configure explicit timeouts to detect and recover from dead connections
faster, especially on Windows where firewalls may silently drop HTTP/2
keep-alive pings:

- Http2KeepAliveTimeout (5s): Close connection if ping not acknowledged
- Http2KeepAliveWhileIdle: Continue pinging during idle periods
- ConnectTimeout (10s): Don't wait forever for initial connection

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 19:10:44 -08:00
d473e5a5d6 Fix deadlock in PersistentClientConnection.Dispose() (#4850)
The Dispose() method held lock(this) while calling Thread.Join() on the
streaming thread. If the streaming thread was trying to acquire the same
lock, this caused a deadlock - making "Return to Lobby" hang indefinitely.

Fix: Capture thread reference inside lock, then join OUTSIDE the lock.

Also adds diagnostic logging for sync mismatch debugging:
- Heartbeat now logs client's count per game
- Result count updates are logged when they change

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 19:01:10 -08:00
e9b980e6d0 Add protoless shouldRest overload to CommandChoiceHelpers (#4849)
- Add protoless shouldRest(Iterable[HeroT]) using HeroUtils.fatigue
- Rename proto version to shouldRestProto(Iterable[Hero])
- Update callers (PerformVassalCommandsPhaseAction) to use shouldRestProto
- Add protoless tests using HeroC
- Add hero_utils dependency to BUILD.bazel

This follows the established pattern for incremental deproto migration.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 18:11:06 -08:00
8ba4268832 Deproto: Remove proto methods and convert tests to protoless (#4848)
* Remove SwornBrotherChooser.bestChoiceProto and convert test to protoless

- Delete bestChoiceProto method from SwornBrotherChooser
- Convert SwornBrotherChooserTest to use HeroC (protoless) instead of proto Hero
- Remove proto and LegacyHeroUtils dependencies from sworn_brother_chooser target
- Update DEPROTO_PLAN.md to mark SwornBrotherChooser as fully protoless

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Convert ImproveCommandSelectorTest to protoless and update DEPROTO_PLAN.md

- Replace proto Hero/Province/GameState with native HeroC/ProvinceC/GameState
- Remove GameStateConverter.fromProto calls - test now uses native types directly
- Remove proto dependencies from BUILD.bazel
- Update DEPROTO_PLAN.md with comprehensive status of all command selectors:
  - Document 11 fully protoless command selectors
  - Document 10 protoless quest command selectors
  - Document 2 files with dual proto/protoless versions
  - Document 4 files still blocked on proto GameState

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 17:17:17 -08:00
3a033ddf19 Fix admin server: use explicit goos/goarch for Linux binary (#4847)
The --platforms flag doesn't affect oci_image/pkg_tar dependencies -
they're exec rules that run on the host. This caused the Go binary
to be built for darwin/arm64 instead of linux/amd64.

Fix by creating an explicit admin_server_linux_amd64 target with
goos = "linux" and goarch = "amd64" set in the BUILD file.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 13:55:09 -08:00
61196d05e7 Add Go admin server to Docker deployment (#4846)
- Add oci_image, oci_load, oci_push targets for admin_server in ci/BUILD.bazel
- Add Alpine Linux 3.21 base image to MODULE.bazel (lightweight for Go binary)
- Add admin service to docker-compose.prod.yml with health check
- Add build-admin job to GitHub Actions workflow
- Update deploy job to pull and deploy admin image alongside eagle/shardok

The admin server connects to Eagle via internal Docker network (eagle:40032)
and exposes HTTP on port 8080 for the admin console.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 12:45:01 -08:00
e5a71bd3b4 Add Go admin server to Docker deployment (#4843)
* Add Go admin server to Docker deployment

- Add oci_image, oci_load, oci_push targets for admin_server in ci/BUILD.bazel
- Add Alpine Linux 3.21 base image to MODULE.bazel (lightweight for Go binary)
- Add admin service to docker-compose.prod.yml with health check
- Add build-admin job to GitHub Actions workflow
- Update deploy job to pull and deploy admin image alongside eagle/shardok

The admin server connects to Eagle via internal Docker network (eagle:40032)
and exposes HTTP on port 8080 for the admin console.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix Go cross-compilation by enabling pure Go build

Add `pure = "on"` to admin_server go_binary to disable CGO.
This avoids linker errors when cross-compiling from macOS ARM64
to Linux x86_64 with the LLVM toolchain.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Use rules_go platform for Go cross-compilation

Use @io_bazel_rules_go//go/toolchain:linux_amd64 instead of
//:linux_x86_64 for the admin server build. The rules_go platform
uses Go's native cross-compiler and doesn't involve the LLVM C
toolchain, avoiding linker errors.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 11:46:07 -08:00
e545d9c616 Remove ActionPointDistances disk cache (#4844)
Profiling showed that GenerateDistances accounts for only 0.04% of
total runtime while the disk cache consumed 168 GB of storage. The
modifier hash explosion (ice melt, fire, etc.) caused unbounded cache
growth without providing meaningful performance benefit.

The in-memory caching layers (persistent, thread-local, and shared)
remain and provide effective caching for the hot paths.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 11:24:08 -08:00
17e5135915 Add OPENAI_API_KEY and GPT_MODEL_NAME to deploy .env file (#4842)
- Pass all required env vars from GitHub secrets
- Fix .env file permissions (rm before write, chmod 600)

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 10:57:47 -08:00
5c9623717d Convert SeekMoreLeadersCommandChooser and ProvinceDistances to protoless (#4840)
- Split ProvinceDistances into protoless version + LegacyProvinceDistances
- Convert SeekMoreLeadersCommandChooser to protoless (MidGameAIClient converts proto→Scala)
- Add protoless overloads to FactionUtils.provinces and CommandChoiceHelpers
- Create protoless SeekMoreLeadersCommandChooserTest using Scala model types
- Add DEPROTO_PLAN.md documenting migration status and decisions
- Update visibility for model state concrete types to support AI tests

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 10:49:34 -08:00
2ed1c5769e Enable S3/DO Spaces persistence via environment variables (#4839)
* Enable S3/DO Spaces persistence via environment variables

Add support for persisting game saves to DigitalOcean Spaces (S3-compatible)
via environment variables, preventing game state loss during deployments.

Configuration:
- EAGLE_ENABLE_S3=true to enable S3 persistence
- DO_SPACES_ACCESS_KEY and DO_SPACES_SECRET_KEY for credentials
- Falls back to ~/.s3cfg if env vars not set

Changes:
- S3Credentials: Load credentials from env vars or ~/.s3cfg
- LocalGamePersisterCreation: Read EAGLE_ENABLE_S3 from env
- ServerSetupHelpers: Enable S3 persister based on env var
- S3Utils: Fix transferManager to use credentials
- docker-compose.prod.yml: Add S3 environment variables

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Centralize S3 config and add endpoint env var

- Move all S3 config to S3Credentials (single source of truth)
- Add DO_SPACES_ENDPOINT env var (defaults to sfo3.digitaloceanspaces.com)
- Remove duplicate config reading from LocalGamePersisterCreation and ServerSetupHelpers
- Pass S3 secrets from GitHub Actions to droplet via .env file

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 07:55:01 -08:00
7a3b382623 Build Eagle Docker image for linux_x86_64 platform (#4838)
The Eagle image was being built without --platforms, defaulting to the
self-hosted runner's platform (darwin/arm64). This caused digest mismatch
errors when trying to pull the image on the linux/amd64 production droplet.

Also fix the bazel-bin symlink issue: when building with --platforms,
the output goes to a platform-specific directory. Save the resolved path
immediately after build (before other bazel commands change the symlink)
and use that path for pushing.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 07:13:02 -08:00
d1e4ba4efe Delete unused ActionResultProtoUpdater type alias (#4836)
- Delete package.scala containing the unused ActionResultProtoUpdater type alias
- Remove action_pkg target from BUILD.bazel
- Remove unnecessary action_pkg dependencies from quest_creation BUILD targets

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 22:25:42 -08:00
63 changed files with 1661 additions and 918 deletions
+165 -15
View File
@@ -5,6 +5,7 @@ on:
branches: [ "main" ]
paths:
- 'src/main/cpp/**'
- 'src/main/go/**'
- 'src/main/scala/**'
- 'src/main/protobuf/**'
- 'src/main/resources/**'
@@ -34,7 +35,15 @@ jobs:
lfs: false
- name: Build Eagle Docker image
run: bazel build //ci:eagle_server_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')
@@ -56,6 +65,40 @@ jobs:
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
@@ -67,7 +110,13 @@ jobs:
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 bazel-bin/ci/eagle_server_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
@@ -229,14 +278,90 @@ jobs:
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"
deploy:
runs-on: ubuntu-latest
needs: [build-eagle, build-shardok]
needs: [build-eagle, build-shardok, build-admin]
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 }}
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 }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -257,29 +382,54 @@ jobs:
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
script_stop: true
envs: EAGLE_IMAGE,SHARDOK_IMAGE
envs: EAGLE_IMAGE,SHARDOK_IMAGE,ADMIN_IMAGE,OPENAI_API_KEY,GPT_MODEL_NAME,EAGLE_ENABLE_S3,DO_SPACES_ACCESS_KEY,DO_SPACES_SECRET_KEY
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
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:-}
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"
echo "Using images: $EAGLE_IMAGE, $SHARDOK_IMAGE, $ADMIN_IMAGE"
# Remove cached images to avoid digest mismatch errors
# Docker/containerd caches manifests locally which can conflict with registry
echo "Removing cached images to avoid digest conflicts..."
docker image rm "$EAGLE_IMAGE" 2>/dev/null || true
docker image rm "$SHARDOK_IMAGE" 2>/dev/null || true
# 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
# Pull fresh images with exact SHA tags
echo "Pulling Eagle image: $EAGLE_IMAGE"
docker pull "${EAGLE_IMAGE}" || { echo "ERROR: Failed to pull eagle image"; exit 1; }
# 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: $SHARDOK_IMAGE"
docker pull "${SHARDOK_IMAGE}" || { echo "ERROR: Failed to pull shardok image"; exit 1; }
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
rm ./crane
# Also pull other compose images
docker pull nginx:alpine || true
+90
View File
@@ -0,0 +1,90 @@
# 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
## 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] `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`
### Dual (both proto and protoless versions)
- [~] `ProvinceGoldSurplusCalculator` - protoless `provinceGoldSurplus(province: ProvinceT)` + legacy `provinceGoldSurplus(provinceId, gameState)`
- [~] `HeroSelector` - protoless `minimallyFatiguedHeroes` + legacy `minimallyFatiguedHeroesProto`
### Blocked (still uses proto GameState)
- [ ] `CommandChoiceHelpers` - main target, uses proto GameState extensively
- Only 1 call to `minimallyFatiguedHeroesProto` (HeroSelector)
- Many calls to proto `provinceGoldSurplus`
- Depends on many Legacy* utils
- [ ] `AttackDecisionCommandChooser` - uses proto types
- [ ] `CommandChooser` - uses proto GameState
- [ ] `FulfillQuestsCommandSelector` - takes proto, converts to native immediately
- Called by `MidGameAIClient` which uses proto GameState
## 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
+9 -1
View File
@@ -145,7 +145,15 @@ oci.pull(
platforms = ["linux/amd64"],
tag = "24.04",
)
use_repo(oci, "eclipse_temurin_17", "eclipse_temurin_17_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64")
# 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
+35 -2
View File
@@ -1293,7 +1293,7 @@
"@@rules_oci~//oci:extensions.bzl%oci": {
"general": {
"bzlTransitiveDigest": "FaY+7xb13bB3hmxqwAWaGp3Tf3Q4Nfdlr+F38CP5mcg=",
"usagesDigest": "BuciKSozbpJMD9EP+j0RG5ZgrYMeDPsQyiOnLUni2V8=",
"usagesDigest": "KXZUVR9ea29hTmhxC4+BG0pTXTijLpsFrDVraHG4OyU=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
@@ -1360,6 +1360,37 @@
"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",
@@ -1496,7 +1527,9 @@
"eclipse_temurin_17",
"eclipse_temurin_17_linux_amd64",
"ubuntu_24_04",
"ubuntu_24_04_linux_amd64"
"ubuntu_24_04_linux_amd64",
"alpine_linux",
"alpine_linux_linux_amd64"
],
"explicitRootModuleDirectDevDeps": [],
"useAllRepos": "NO",
+41
View File
@@ -150,3 +150,44 @@ oci_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",
)
+29
View File
@@ -20,6 +20,10 @@ services:
- "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:-}"
volumes:
- ./saves:/app/saves
depends_on:
@@ -80,6 +84,31 @@ services:
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"
- "--http-port"
- "8080"
ports:
- "8080:8080"
depends_on:
- eagle
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
certbot:
image: certbot/certbot
container_name: certbot
@@ -10,7 +10,6 @@
#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;
@@ -109,10 +108,6 @@ 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]);
@@ -16,7 +16,6 @@
#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;
@@ -84,10 +83,6 @@ 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";
@@ -29,8 +29,6 @@ thread_local struct {
int localMisses = 0;
int sharedAccesses = 0;
int evictionEvents = 0;
int apdLoadedFromFile = 0;
int apdGeneratedFresh = 0;
std::chrono::steady_clock::time_point lastReportTime = std::chrono::steady_clock::now();
} cacheStats;
@@ -40,16 +38,13 @@ static void MaybePrintCacheStats() {
if (std::chrono::duration_cast<std::chrono::seconds>(now - cacheStats.lastReportTime).count() >=
CACHE_STATS_FREQUENCY_SECONDS_) {
printf("Thread cache stats: %d persistent hits, %d persistent misses, %d local hits, "
"%d local misses, %d shared accesses, %d eviction events, "
"%d APD loaded from file, %d APD generated fresh\n",
"%d local misses, %d shared accesses, %d eviction events\n",
cacheStats.persistentHits,
cacheStats.persistentMisses,
cacheStats.localHits,
cacheStats.localMisses,
cacheStats.sharedAccesses,
cacheStats.evictionEvents,
cacheStats.apdLoadedFromFile,
cacheStats.apdGeneratedFresh);
cacheStats.evictionEvents);
cacheStats.lastReportTime = now;
}
}
@@ -195,25 +190,12 @@ auto ActionPointDistancesCache::GetRaw(
}
// Create new pathfinding result using factory method
auto creationResult = FixedActionPointDistances::Create(
auto result = FixedActionPointDistances::Create(
mapToUse,
mapId.terrainTypesId,
mapId.modifierId,
battalionType,
includeBravingWater,
braveWaterActionPointCost);
#if CACHE_STATS_LOGGING_
// Track whether this was loaded from file or generated fresh
if (creationResult.loadedFromFile) {
cacheStats.apdLoadedFromFile++;
} else {
cacheStats.apdGeneratedFresh++;
}
#endif
auto result = creationResult.apd;
// Store in shared cache
sharedDistances.lazy_emplace_l(
cacheKey,
@@ -22,110 +22,56 @@ static const int ASYNC_COUNT = []() {
namespace shardok {
void FixedActionPointDistances::SetCacheDirectory(const string& newDir) {
cacheDirectory = newDir;
FilesystemUtils::MakeDirectoryIfNecessary(cacheDirectory);
}
static thread_local byte_vector _scratch;
FixedActionPointDistances::FixedActionPointDistances(const HexMap* /*map*/, int columnCount)
: ActionPointDistances(columnCount) {}
auto FixedActionPointDistances::Create(
const HexMap* map,
int64_t terrainTypesHash,
int64_t modifierHash,
const BattalionTypeSPtr& battalionType,
bool includeBravingWater,
int braveWaterActionPointCost) -> CreationResult {
int braveWaterActionPointCost) -> std::shared_ptr<FixedActionPointDistances> {
// Create the object using private constructor
auto apd = std::shared_ptr<FixedActionPointDistances>(
new FixedActionPointDistances(map, map->column_count()));
CreationResult result;
result.apd = apd;
result.loadedFromFile = false;
string path = "";
if (!cacheDirectory.empty()) {
std::stringstream stream;
stream << cacheDirectory;
stream << std::hex << terrainTypesHash << '/';
if (auto directoryPath = stream.str(); !FilesystemUtils::FileExistsAtPath(directoryPath)) {
FilesystemUtils::MakeDirectoryIfNecessary(stream.str());
}
stream << std::hex << modifierHash;
stream << " " << battalionType->typeId;
if (includeBravingWater) { stream << " " << braveWaterActionPointCost; }
stream << ".apd";
path = stream.str();
}
const int indexCount = map->row_count() * map->column_count();
if (!path.empty() && FilesystemUtils::FileExistsAtPath(path)) {
apd->distances.resize(indexCount);
// load from file
const auto& bytes = _scratch.ReplaceWithPath(path);
const auto* ptr = reinterpret_cast<const DIST_T*>(bytes.data());
vector<std::future<vector<vector<DIST_T>>>> futures(indexCount);
for (int fromIndex = 0; fromIndex < indexCount; fromIndex++) {
apd->distances[fromIndex].insert(
apd->distances[fromIndex].end(),
&(ptr[0]),
&(ptr[indexCount]));
ptr += indexCount;
}
result.loadedFromFile = true;
} else {
_scratch.reserve(indexCount * indexCount * sizeof(DIST_T));
auto braveWaterPossibleCoords =
includeBravingWater ? apd->BraveWaterPossibleCoords(map) : nullptr;
vector<std::future<vector<vector<DIST_T>>>> futures(indexCount);
int chunkSize = (indexCount + ASYNC_COUNT - 1) / ASYNC_COUNT;
// Break into chunks for async
for (int chunkIdx = 0; chunkIdx < ASYNC_COUNT; chunkIdx++) {
futures[chunkIdx] = std::async(std::launch::async, [=]() -> vector<vector<DIST_T>> {
vector<vector<DIST_T>> chunkVec;
chunkVec.reserve(chunkSize);
const int chunkStartIndex = chunkIdx * chunkSize;
auto braveWaterPossibleCoords =
includeBravingWater ? apd->BraveWaterPossibleCoords(map) : nullptr;
int chunkSize = (indexCount + ASYNC_COUNT - 1) / ASYNC_COUNT;
// Break into chunks for async
for (int chunkIdx = 0; chunkIdx < ASYNC_COUNT; chunkIdx++) {
futures[chunkIdx] = std::async(std::launch::async, [=]() -> vector<vector<DIST_T>> {
vector<vector<DIST_T>> chunkVec;
chunkVec.reserve(chunkSize);
const int chunkStartIndex = chunkIdx * chunkSize;
for (int i = 0; i < chunkSize; i++) {
const auto fromIndex = chunkStartIndex + i;
if (fromIndex >= indexCount) { continue; }
chunkVec.push_back(ActionPointDistances::GenerateDistances(
fromIndex,
map,
includeBravingWater,
braveWaterActionPointCost,
battalionType,
braveWaterPossibleCoords));
}
return chunkVec;
});
}
apd->distances.reserve(indexCount);
_scratch.clear();
_scratch.reserve(indexCount * indexCount * sizeof(DIST_T));
for (int chunkIdx = 0; chunkIdx < ASYNC_COUNT; chunkIdx++) {
auto resultsVec = futures[chunkIdx].get();
apd->distances.insert(apd->distances.end(), resultsVec.begin(), resultsVec.end());
for (const auto& r : resultsVec) { _scratch.append(r); }
}
if (!path.empty()) { FilesystemUtils::AtomicallySaveToPath(path, _scratch); }
for (int i = 0; i < chunkSize; i++) {
const auto fromIndex = chunkStartIndex + i;
if (fromIndex >= indexCount) { continue; }
chunkVec.push_back(ActionPointDistances::GenerateDistances(
fromIndex,
map,
includeBravingWater,
braveWaterActionPointCost,
battalionType,
braveWaterPossibleCoords));
}
return chunkVec;
});
}
return result;
apd->distances.reserve(indexCount);
for (int chunkIdx = 0; chunkIdx < ASYNC_COUNT; chunkIdx++) {
auto resultsVec = futures[chunkIdx].get();
apd->distances.insert(apd->distances.end(), resultsVec.begin(), resultsVec.end());
}
return apd;
}
} // namespace shardok
@@ -17,31 +17,19 @@ using std::vector;
using BattalionTypeSPtr = std::shared_ptr<const BattalionType>;
class FixedActionPointDistances final : public ActionPointDistances {
public:
struct CreationResult {
std::shared_ptr<FixedActionPointDistances> apd;
bool loadedFromFile;
};
private:
vector<vector<DIST_T>> distances;
inline static string cacheDirectory = "";
// Private constructor - use Create factory method instead
explicit FixedActionPointDistances(const HexMap *map, int columnCount);
public:
static void SetCacheDirectory(const string &newDir);
// Factory method to create FixedActionPointDistances with metadata
// Factory method to create FixedActionPointDistances
static auto Create(
const HexMap *map,
int64_t terrainTypesHash,
int64_t modifierHash,
const BattalionTypeSPtr &battalionType,
bool includeBravingWater,
int braveWaterActionPointCost = -1) -> CreationResult;
int braveWaterActionPointCost = -1) -> std::shared_ptr<FixedActionPointDistances>;
~FixedActionPointDistances() override = default;
@@ -52,8 +40,6 @@ public:
[[nodiscard]] auto Distance(const Coords &from, const Coords &to) const -> DIST_T override {
return Distance(ToIndex(from), ToIndex(to));
}
friend struct CreationResult;
};
} // namespace shardok
@@ -15,7 +15,6 @@
#include "ServerConfiguration.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
#include "src/main/cpp/net/eagle0/shardok/controller/ShardokGameController.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings_loader/SettingsLoader.hpp"
#include "src/main/cpp/net/eagle0/shardok/util/MapLoader.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/unit.hpp"
@@ -45,9 +44,6 @@ ShardokGamesManager::ShardokGamesManager(const std::vector<std::string> &extraSe
std::cerr << "NOT SETTING" << std::endl;
// setter.SetRaw(key, value);
}
FixedActionPointDistances::SetCacheDirectory(
FilesystemUtils::CacheFilesDirectory() + "apdCache/");
}
auto ShardokGamesManager::GetController(const GameId &gameId)
@@ -311,20 +311,36 @@ namespace eagle {
// Store server-reported game status for UI
if (updateItem.ActionResultResponse.ServerGameStatus != null) {
ServerStatus = updateItem.ActionResultResponse.ServerGameStatus;
_connectionLogger.LogLine(
$"[UPDATE] ServerGameStatus updated: {ServerStatus.Status}");
}
// Note: _lastUnfilteredResultCount is updated on the gRPC thread in
// UpdateResultCounts() before enqueueing. We don't update it here to avoid
// race conditions where a backlogged MainQueue update overwrites a newer count.
if (updateItem.ActionResultResponse.ActionResultViews.Any() ||
updateItem.ActionResultResponse.AvailableCommands == null ||
updateItem.ActionResultResponse.AvailableCommands.Token !=
_currentModel.CommandToken) {
var hasResults = updateItem.ActionResultResponse.ActionResultViews.Any();
var hasCommands = updateItem.ActionResultResponse.AvailableCommands != null;
var incomingToken =
hasCommands ? updateItem.ActionResultResponse.AvailableCommands.Token
: -1;
var tokenMatches = hasCommands && incomingToken == _currentModel.CommandToken;
_connectionLogger.LogLine(
$"[UPDATE] hasResults={hasResults}, hasCommands={hasCommands}, " +
$"incomingToken={incomingToken}, currentToken={_currentModel.CommandTokenString}, " +
$"tokenMatches={tokenMatches}");
if (hasResults || !hasCommands || !tokenMatches) {
HandleUpdates(updateItem.ActionResultResponse.ActionResultViews.ToList());
HandleAvailableCommands(updateItem.ActionResultResponse.AvailableCommands);
if (UpdateAction != null) UpdateAction.Invoke(_currentModel);
_connectionLogger.LogLine(
"[UPDATE] Processed update and invoked UpdateAction");
} else {
_connectionLogger.LogLine(
"[UPDATE] SKIPPED - no results, has commands, token matches");
}
break;
@@ -405,9 +421,14 @@ namespace eagle {
public void UpdateResultCounts(GameUpdate update) {
switch (update.GameUpdateDetailsCase) {
case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
var newCount = update.ActionResultResponse.UnfilteredResultCountAfter;
lock (_resultCountLock) {
_lastUnfilteredResultCount =
update.ActionResultResponse.UnfilteredResultCountAfter;
var oldCount = _lastUnfilteredResultCount;
_lastUnfilteredResultCount = newCount;
if (newCount != oldCount) {
_connectionLogger.LogLine(
$"[RESULT_COUNT] Updated count {oldCount} -> {newCount}");
}
}
break;
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e9afdd2068b294a29993f07b379eb9c3
@@ -44,6 +44,12 @@ namespace eagle {
private readonly Logger _remoteEagleClientLogger = Logger.GetLogger("ConnectionLogger");
private readonly Logger _timingsLogger = Logger.GetLogger("ConnectionLogger");
/// <summary>Timestamped log for connection flow tracing.</summary>
private void LogFlow(string message) {
var ts = DateTime.UtcNow.ToString("HH:mm:ss.fff");
_remoteEagleClientLogger.LogLine($"[FLOW {ts}] {message}");
}
private volatile bool _isConnecting = false;
private Timer _idleCheckTimer = null;
private Timer _heartbeatTimer = null;
@@ -122,6 +128,7 @@ namespace eagle {
_currentState = ConnectionState.Reconnecting;
NextReconnectAttempt = DateTime.UtcNow.AddSeconds(backoffSeconds);
LogFlow($"SCHEDULE_RECONNECT reason={reason} backoff={backoffSeconds:F1}s attempt={_consecutiveFailures}");
LogConnectionEvent(
"schedule_reconnect",
$"{reason}, backoff={backoffSeconds:F1}s, attempt={_consecutiveFailures}");
@@ -194,16 +201,14 @@ namespace eagle {
var ackTcs = new TaskCompletionSource<SubscriptionAck>();
lock (_pendingSubscriptionAcks) { _pendingSubscriptionAcks[gameId] = ackTcs; }
_remoteEagleClientLogger.LogLine(
$"[SUBSCRIBE] Sending StreamGameRequest for game {gameId}, " +
$"unfilteredCount={subscriber.LastUnfilteredResultCount}");
var shardokCount = shardokStatuses.Count();
LogFlow($"SUBSCRIBE game={gameId} unfilteredCount={subscriber.LastUnfilteredResultCount} shardokGames={shardokCount}");
var sendSuccess = await DoWithStreamingCall(async (sc) => {
await sc.RequestStream.WriteAsync(request);
_remoteEagleClientLogger.LogLine(
$"[SUBSCRIBE] StreamGameRequest sent successfully for game {gameId}");
return true;
});
await sc.RequestStream.WriteAsync(request).ConfigureAwait(false);
LogFlow($"SUBSCRIBE sent for game={gameId}");
return true;
}).ConfigureAwait(false);
if (!sendSuccess) {
// Write failed - clean up and return
@@ -217,7 +222,7 @@ namespace eagle {
try {
var ackTask = ackTcs.Task;
var timeoutTask = Task.Delay(SubscriptionAckTimeoutMs, timeoutCts.Token);
var completedTask = await Task.WhenAny(ackTask, timeoutTask);
var completedTask = await Task.WhenAny(ackTask, timeoutTask).ConfigureAwait(false);
if (completedTask == timeoutTask) {
// Timeout waiting for ack
@@ -231,24 +236,15 @@ namespace eagle {
// Cancel the timeout task since ack was received
timeoutCts.Cancel();
var ack = await ackTask;
var ack = await ackTask.ConfigureAwait(false);
if (ack.Success) {
_remoteEagleClientLogger.LogLine(
$"[SUBSCRIBE] Subscription confirmed for game {gameId}, " +
$"confirmedResultCount={ack.ConfirmedResultCount}");
// Note: Shardok resync flags are cleared in EagleGameModel.HandleOneGameUpdate
// AFTER updates are actually received, not here. This ensures that if the
// connection drops between acknowledgment and update delivery, the resync
// will be requested again on the next reconnect.
LogFlow($"SUBSCRIBE_ACK game={gameId} success=true confirmedCount={ack.ConfirmedResultCount}");
return true;
} else {
LogFlow($"SUBSCRIBE_ACK game={gameId} success=false error={ack.ErrorMessage}");
LogConnectionEvent(
"subscribe_ack_failed",
$"game={gameId}, error={ack.ErrorMessage}");
_remoteEagleClientLogger.LogLine(
$"[SUBSCRIBE] Server rejected subscription for game {gameId}: {ack.ErrorMessage}");
return false;
}
} catch (OperationCanceledException) {
@@ -266,9 +262,11 @@ namespace eagle {
}
public async Task Connect() {
LogFlow($"Connect() called, state={_currentState}, failures={_consecutiveFailures}");
// Prevent concurrent connection attempts
if (_isConnecting) {
_remoteEagleClientLogger.LogLine($"Connect() skipped - already connecting");
LogFlow("Connect() skipped - already connecting");
return;
}
@@ -286,6 +284,7 @@ namespace eagle {
_currentState = _consecutiveFailures > 0 ? ConnectionState.Reconnecting
: ConnectionState.Connecting;
NextReconnectAttempt = null;
LogFlow($"State -> {_currentState}");
LogConnectionEvent("connect_attempt");
// Dispose existing streaming call before creating new one
@@ -318,13 +317,19 @@ namespace eagle {
// Stream subscriptions OUTSIDE lock (can await)
// Set state to SubscriptionPending while waiting for acks
LogFlow($"Stream created, {subscribersToStream.Count} subscribers to stream");
if (subscribersToStream.Any()) {
_currentState = ConnectionState.SubscriptionPending;
LogFlow($"State -> {_currentState}");
}
bool allSucceeded = true;
foreach (var subscriber in subscribersToStream) {
if (!await StreamOneGameAsync(subscriber)) { allSucceeded = false; }
LogFlow($"Subscribing game {subscriber.GameId}...");
if (!await StreamOneGameAsync(subscriber)) {
LogFlow($"Subscription FAILED for game {subscriber.GameId}");
allSucceeded = false;
}
}
if (!allSucceeded) {
@@ -353,6 +358,7 @@ namespace eagle {
_lastSuccessfulConnect = DateTime.UtcNow;
_consecutiveFailures = 0; // Reset backoff on successful connection
_currentState = ConnectionState.Connected;
LogFlow($"State -> {_currentState} (all subscriptions succeeded)");
_circuitBreaker.RecordSuccess();
LogConnectionEvent("connect_success");
@@ -470,8 +476,8 @@ namespace eagle {
break;
}
}
await PostRequest(nextCommand);
// Note: PostRequest is called inside each case, not here
// (removed duplicate PostRequest call that was causing double-posting)
}
}
@@ -503,6 +509,9 @@ namespace eagle {
}
public void Dispose() {
Thread threadToJoin = null;
CancellationTokenSource tokenSourceToDispose = null;
lock (this) {
// Dispose timers first to stop any pending callbacks
StopIdleCheckTimer();
@@ -525,20 +534,25 @@ namespace eagle {
// Clear pending commands
_pendingCommands.Clear();
// Cancel and dispose thread cancellation token
// Cancel thread cancellation token (but don't dispose yet)
_threadCancellationTokenSource?.Cancel();
// Give thread a chance to exit gracefully
if (_streamingCallThread != null) {
if (!_streamingCallThread.Join(TimeSpan.FromSeconds(2))) {
Console.WriteLine("Warning: Streaming call thread did not exit gracefully");
}
_streamingCallThread = null;
}
_threadCancellationTokenSource?.Dispose();
// Capture thread and token source references for cleanup outside lock
threadToJoin = _streamingCallThread;
_streamingCallThread = null;
tokenSourceToDispose = _threadCancellationTokenSource;
_threadCancellationTokenSource = null;
}
// Join thread OUTSIDE lock to avoid deadlock - the streaming thread
// may be trying to acquire lock(this) when we're waiting for it
if (threadToJoin != null) {
if (!threadToJoin.Join(TimeSpan.FromSeconds(2))) {
Console.WriteLine("Warning: Streaming call thread did not exit gracefully");
}
}
tokenSourceToDispose?.Dispose();
}
public void SetLobbySubscriber(ILobbySubscriber subscriber) {
@@ -562,16 +576,18 @@ namespace eagle {
public async Task PostError(EagleGameId gameId, string errorMessage, string stackTrace) {
await DoWithStreamingCall(async (streamingCall) => {
await streamingCall.RequestStream.WriteAsync(new UpdateStreamRequest {
ErrorRequest =
new ErrorRequest {
ErrorMessage = errorMessage,
GameId = gameId,
StackTrace = stackTrace
}
});
await streamingCall.RequestStream
.WriteAsync(new UpdateStreamRequest {
ErrorRequest =
new ErrorRequest {
ErrorMessage = errorMessage,
GameId = gameId,
StackTrace = stackTrace
}
})
.ConfigureAwait(false);
return true;
});
}).ConfigureAwait(false);
}
public void Unsubscribe(IClientConnectionSubscriber subscriber) {
@@ -583,10 +599,11 @@ namespace eagle {
lock (this) { _pendingCommands.Add(request); }
await DoWithStreamingCall(async (streamingCall) => {
await streamingCall.RequestStream.WriteAsync(
new UpdateStreamRequest { PostCommandRequest = request });
await streamingCall.RequestStream
.WriteAsync(new UpdateStreamRequest { PostCommandRequest = request })
.ConfigureAwait(false);
return true;
});
}).ConfigureAwait(false);
lock (this) { _pendingCommands.Remove(request); }
} catch (Exception e) { Console.WriteLine("Failed to post command: " + e); }
}
@@ -667,8 +684,20 @@ namespace eagle {
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> call);
private async Task<bool> DoWithStreamingCall(WithStreamingCallDelegate action) {
try {
return await action(_streamingCall);
} catch (RpcException e) {
var actionTask = action(_streamingCall);
var timeoutTask = Task.Delay(WriteTimeoutMs, _cancellationToken);
var completedTask =
await Task.WhenAny(actionTask, timeoutTask).ConfigureAwait(false);
if (completedTask == timeoutTask) {
_remoteEagleClientLogger.LogLine(
"[WRITE] DoWithStreamingCall timed out - connection may be dead");
return false;
}
return await actionTask.ConfigureAwait(false);
} catch (OperationCanceledException) { return false; } catch (RpcException e) {
if (e.StatusCode == StatusCode.Cancelled) {
// This is expected when the connection is closed.
return false;
@@ -678,6 +707,9 @@ namespace eagle {
}
}
// Timeout for WriteAsync to prevent ThreadPool exhaustion from blocked writes
private const int WriteTimeoutMs = 10000;
public async Task<bool> SendUpdateStreamRequestAsync(UpdateStreamRequest request) {
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> sc = null;
lock (this) {
@@ -686,8 +718,18 @@ namespace eagle {
}
try {
await sc.RequestStream.WriteAsync(request, _cancellationToken);
// Use timeout to prevent indefinite blocking on dead connections
using var timeoutCts =
CancellationTokenSource.CreateLinkedTokenSource(_cancellationToken);
timeoutCts.CancelAfter(WriteTimeoutMs);
await sc.RequestStream.WriteAsync(request, timeoutCts.Token).ConfigureAwait(false);
return true;
} catch (OperationCanceledException) {
// Timeout or cancellation - connection is likely dead
_remoteEagleClientLogger.LogLine(
"[WRITE] WriteAsync timed out or cancelled - connection may be dead");
return false;
} catch (RpcException e) {
if (e.StatusCode == StatusCode.Cancelled) {
// This is expected when the connection is closed.
@@ -706,7 +748,7 @@ namespace eagle {
private void HandleGameUpdate(GameUpdate gameUpdate, DateTime receivedTime) {
switch (gameUpdate.GameUpdateDetailsCase) {
case GameUpdate.GameUpdateDetailsOneofCase.ErrorResponse:
_remoteEagleClientLogger.LogLine("Got an error response!");
LogFlow($"UPDATE ErrorResponse game={gameUpdate.GameId}");
MainQueue.Q.Enqueue(() => {
lock (this) { _subscribers.Remove(gameUpdate.GameId); }
});
@@ -714,7 +756,6 @@ namespace eagle {
case GameUpdate.GameUpdateDetailsOneofCase.StreamingTextResponse:
// Handle streaming text directly on gRPC thread - dictionary is thread-safe.
// Listeners are notified later via ProcessPendingUpdates() on main thread.
var str = gameUpdate.StreamingTextResponse;
if (str != null) {
ClientTextProvider.Provider.HandleNewStreamingText(
@@ -723,15 +764,22 @@ namespace eagle {
str.StartingByteCount,
str.Completed);
}
// No MainQueue enqueue needed - ProcessPendingUpdates handles listener
// notification
break;
case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
// Update result counts IMMEDIATELY on the gRPC thread, before enqueueing.
// This ensures reconnects use accurate counts even when MainQueue is blocked
// (e.g., when Unity is backgrounded).
var arResp = gameUpdate.ActionResultResponse;
var arCount = arResp.ActionResultViews?.Count ?? 0;
var hasStartingState = gameUpdate.StartingState != null;
var hasCommands = arResp.AvailableCommands != null;
var cmdToken = hasCommands ? arResp.AvailableCommands.Token : -1;
var cmdCount = hasCommands
? arResp.AvailableCommands.CommandsByProvince?.Count ?? 0
: 0;
var status = arResp.ServerGameStatus?.Status.ToString() ?? "null";
LogFlow($"UPDATE ActionResult game={gameUpdate.GameId} actionResults={arCount} " +
$"startingState={hasStartingState} hasCommands={hasCommands} " +
$"token={cmdToken} cmdProvinces={cmdCount} status={status}");
lock (this) {
if (_subscribers.TryGetValue(gameUpdate.GameId, out var sub)) {
sub.UpdateResultCounts(gameUpdate);
@@ -747,19 +795,38 @@ namespace eagle {
_subscribers.Remove(gameUpdate.GameId);
}
}
await TryPendingCommands();
});
break;
var processTime = (DateTime.UtcNow - receivedTime).TotalMilliseconds;
if (processTime > 100.0) {
_timingsLogger.LogLine($"PROCESS {processTime} ms");
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
var shResp = gameUpdate.ShardokActionResultResponse;
var shGames = shResp.ShardokGameResponses?.Count ?? 0;
LogFlow($"UPDATE ShardokResult game={gameUpdate.GameId} shardokGames={shGames}");
lock (this) {
if (_subscribers.TryGetValue(gameUpdate.GameId, out var sub2)) {
sub2.UpdateResultCounts(gameUpdate);
}
}
MainQueue.Q.Enqueue(async () => {
IClientConnectionSubscriber subscriber;
lock (this) {
if (_subscribers.TryGetValue(gameUpdate.GameId, out subscriber)) {
subscriber.ReceiveGameUpdate(gameUpdate);
} else {
_subscribers.Remove(gameUpdate.GameId);
}
}
await TryPendingCommands();
});
break;
}
}
private async void HandleStreamingCall() {
LogFlow("HandleStreamingCall STARTED");
try {
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> sc;
CancellationToken threadToken;
@@ -875,8 +942,10 @@ namespace eagle {
waitStartTime = DateTime.UtcNow;
}
_remoteEagleClientLogger.LogLine(
"How did we get here? This is not my beautiful wife!");
// While loop exited normally (not via exception)
var scNull = sc == null;
var tokenCancelled = _currentThreadToken.IsCancellationRequested;
LogFlow($"HandleStreamingCall ENDED normally: sc_null={scNull} token_cancelled={tokenCancelled}");
} catch (RpcException e) {
lock (this) {
_lastDisconnect = DateTime.UtcNow;
@@ -974,7 +1043,13 @@ namespace eagle {
AutoReset = true,
Interval = 5000 // Check every 5 seconds
};
_idleCheckTimer.Elapsed += (sender, args) => CheckForIdleTimeout();
_idleCheckTimer.Elapsed += (sender, args) => {
try {
CheckForIdleTimeout();
} catch (Exception e) {
LogFlow($"IDLE_TIMER_ERROR {e.GetType().Name}: {e.Message}");
}
};
_idleCheckTimer.Enabled = true;
}
@@ -987,10 +1062,16 @@ namespace eagle {
}
private void CheckForIdleTimeout() {
if (_cancellationToken.IsCancellationRequested) { return; }
var idleTime = (DateTime.UtcNow - _lastResponseReceived).TotalSeconds;
// Log every check when idle > 5s to diagnose timer issues
if (idleTime > 5.0) {
_remoteEagleClientLogger.LogLine(
$"[IDLE_CHECK] idle_time={idleTime:F1}s, cancelled={_cancellationToken.IsCancellationRequested}");
}
if (_cancellationToken.IsCancellationRequested) { return; }
// Early warnings at 10s and 20s to help diagnose slow connections
if (idleTime > 20.0 && _lastIdleWarningLevel < 2) {
_lastIdleWarningLevel = 2;
@@ -1022,7 +1103,19 @@ namespace eagle {
StopHeartbeatTimer();
_heartbeatTimer =
new Timer { AutoReset = true, Interval = HeartbeatIntervalSeconds * 1000 };
_heartbeatTimer.Elapsed += (sender, args) => Task.Run(() => SendHeartbeat());
_heartbeatTimer.Elapsed += (sender, args) => {
try {
Task.Run(async () => {
try {
await SendHeartbeat().ConfigureAwait(false);
} catch (Exception e) {
LogFlow($"HEARTBEAT_ERROR {e.GetType().Name}: {e.Message}");
}
});
} catch (Exception e) {
LogFlow($"HEARTBEAT_TIMER_ERROR {e.GetType().Name}: {e.Message}");
}
};
_heartbeatTimer.Enabled = true;
}
@@ -1035,8 +1128,16 @@ namespace eagle {
}
private async Task SendHeartbeat() {
if (_cancellationToken.IsCancellationRequested) { return; }
if (_currentState != ConnectionState.Connected) { return; }
LogFlow($"HEARTBEAT_START state={_currentState}");
if (_cancellationToken.IsCancellationRequested) {
LogFlow("HEARTBEAT_SKIP reason=cancellation_requested");
return;
}
if (_currentState != ConnectionState.Connected) {
LogFlow($"HEARTBEAT_SKIP reason=not_connected state={_currentState}");
return;
}
// Build sync status for all subscribed games
var heartbeatRequest = new HeartbeatRequest {
@@ -1066,8 +1167,13 @@ namespace eagle {
var sent = await SendUpdateStreamRequestAsync(request);
if (sent) {
_remoteEagleClientLogger.LogLine(
$"[HEARTBEAT] Sent heartbeat with {heartbeatRequest.GameSyncStatuses.Count} games");
var gameInfo = string.Join(
", ",
heartbeatRequest.GameSyncStatuses.Select(
g => $"{g.GameId}:{g.UnfilteredResultCount}"));
LogFlow($"HEARTBEAT_SENT games={heartbeatRequest.GameSyncStatuses.Count} ({gameInfo})");
} else {
LogFlow("HEARTBEAT_FAILED stream_unavailable");
}
}
@@ -1076,31 +1182,19 @@ namespace eagle {
private const double SyncMismatchGracePeriodSeconds = 60.0;
private void HandleHeartbeatResponse(HeartbeatResponse response) {
_remoteEagleClientLogger.LogLine(
$"[HEARTBEAT] Got response, server_timestamp={response.ServerTimestamp}");
LogFlow($"HEARTBEAT_RESPONSE server_ts={response.ServerTimestamp}");
// Check for sync mismatches reported by server
bool hasMismatch = false;
foreach (var syncResult in response.GameSyncResults) {
if (!syncResult.EagleInSync) {
_remoteEagleClientLogger.LogLine(
$"[SYNC_MISMATCH] Game {syncResult.GameId}: Eagle out of sync, " +
$"server has {syncResult.ServerUnfilteredResultCount} results");
LogConnectionEvent(
"sync_mismatch_eagle",
$"game={syncResult.GameId}, server_count={syncResult.ServerUnfilteredResultCount}");
LogFlow($"SYNC_MISMATCH game={syncResult.GameId} server_count={syncResult.ServerUnfilteredResultCount}");
hasMismatch = true;
}
foreach (var shardokResult in syncResult.ShardokSyncResults) {
if (!shardokResult.InSync) {
_remoteEagleClientLogger.LogLine(
$"[SYNC_MISMATCH] Game {syncResult.GameId}, Shardok {shardokResult.ShardokGameId}: " +
$"out of sync, server has {shardokResult.ServerFilteredResultCount} results");
LogConnectionEvent(
"sync_mismatch_shardok",
$"game={syncResult.GameId}, shardok={shardokResult.ShardokGameId}, " +
$"server_count={shardokResult.ServerFilteredResultCount}");
LogFlow($"SYNC_MISMATCH game={syncResult.GameId} shardok={shardokResult.ShardokGameId} server_count={shardokResult.ServerFilteredResultCount}");
hasMismatch = true;
}
}
@@ -1126,6 +1220,9 @@ namespace eagle {
LogConnectionEvent("sync_mismatch_reconnect", "Triggering reconnect to resync");
// Schedule reconnect to resync
// Stop timers FIRST to prevent them firing during reconnection
StopHeartbeatTimer();
StopIdleCheckTimer();
lock (this) {
_streamingCall?.Dispose();
_streamingCall = null;
@@ -28,11 +28,7 @@ public class EagleConnection : IDisposable {
_channel = GrpcChannel.ForAddress(
"https://" + url,
new GrpcChannelOptions {
HttpHandler =
new YetAnotherHttpHandler {
Http2Only = true,
Http2KeepAliveInterval = TimeSpan.FromSeconds(KeepAliveSeconds)
},
HttpHandler = CreateHttpHandler(),
DisposeHttpClient = true,
LoggerFactory = _loggerFactory,
MaxReceiveMessageSize = null
@@ -42,6 +38,25 @@ public class EagleConnection : IDisposable {
return invoker;
}
/// <summary>
/// Create HTTP handler with timeouts configured for reliable connection management.
/// These settings help detect and recover from dead connections, especially on Windows
/// where firewalls may silently drop HTTP/2 keep-alive pings.
/// </summary>
private static YetAnotherHttpHandler CreateHttpHandler() {
return new YetAnotherHttpHandler {
Http2Only = true,
// Send keep-alive pings every 15 seconds
Http2KeepAliveInterval = TimeSpan.FromSeconds(KeepAliveSeconds),
// Close connection if ping not acknowledged within 5 seconds
Http2KeepAliveTimeout = TimeSpan.FromSeconds(5),
// Continue pinging even when idle to detect dead connections
Http2KeepAliveWhileIdle = true,
// Don't wait forever for initial connection
ConnectTimeout = TimeSpan.FromSeconds(10)
};
}
public EagleConnection(string playerName, string password, string url) {
this.playerName = playerName;
credentials = new Metadata { { "user", playerName } };
@@ -6,12 +6,16 @@ using UnityEngine;
namespace common {
public sealed class Logger : IDisposable {
private readonly StreamWriter _sw;
private readonly object _lock = new object();
private static Dictionary<String, Logger> _loggers = new Dictionary<String, Logger>();
private static readonly object _loggersLock = new object();
public static Logger GetLogger(string name) {
if (!_loggers.ContainsKey(name)) { _loggers[name] = new Logger(name); }
return _loggers[name];
lock (_loggersLock) {
if (!_loggers.ContainsKey(name)) { _loggers[name] = new Logger(name); }
return _loggers[name];
}
}
private string CurrentTimeString() {
@@ -29,8 +33,10 @@ namespace common {
}
public void LogLine(string line) {
_sw.WriteLine(CurrentTimeString() + " " + line);
_sw.Flush();
lock (_lock) {
_sw.WriteLine(CurrentTimeString() + " " + line);
_sw.Flush();
}
}
public void Close() { _sw.Close(); }
@@ -1,6 +1,6 @@
{
"dependencies": {
"com.cysharp.yetanotherhttphandler": "https://github.com/Cysharp/YetAnotherHttpHandler.git?path=src/YetAnotherHttpHandler#1.5.3",
"com.cysharp.yetanotherhttphandler": "https://github.com/Cysharp/YetAnotherHttpHandler.git?path=src/YetAnotherHttpHandler#1.11.4",
"com.unity.2d.sprite": "1.0.0",
"com.unity.2d.tilemap": "1.0.0",
"com.unity.ai.navigation": "2.0.9",
@@ -1,11 +1,11 @@
{
"dependencies": {
"com.cysharp.yetanotherhttphandler": {
"version": "https://github.com/Cysharp/YetAnotherHttpHandler.git?path=src/YetAnotherHttpHandler#1.5.3",
"version": "https://github.com/Cysharp/YetAnotherHttpHandler.git?path=src/YetAnotherHttpHandler#1.11.4",
"depth": 0,
"source": "git",
"dependencies": {},
"hash": "ceb2f24f343c93d66c8a784a283be65b9627efe1"
"hash": ""
},
"com.unity.2d.sprite": {
"version": "1.0.0",
@@ -15,5 +15,16 @@ go_library(
go_binary(
name = "admin_server",
embed = [":admin_server_lib"],
pure = "on",
visibility = ["//visibility:public"],
)
# Linux x86_64 target for Docker deployment
go_binary(
name = "admin_server_linux_amd64",
embed = [":admin_server_lib"],
goarch = "amd64",
goos = "linux",
pure = "on",
visibility = ["//visibility:public"],
)
+11 -5
View File
@@ -107,7 +107,7 @@ scala_library(
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
],
@@ -146,7 +146,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_battalion_utils",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:province_gold_surplus_calculator",
],
)
@@ -182,6 +182,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util:date_utils",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
"//src/main/scala/net/eagle0/eagle/library/util:incoming_army_utils",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:alms_command_selector",
@@ -214,6 +215,7 @@ scala_library(
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
],
)
@@ -255,17 +257,21 @@ scala_library(
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/common:more_option",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:loyalty_gain_from_feast",
"//src/main/scala/net/eagle0/eagle/library/settings:minimum_loyalty_for_swear_brotherhood",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:hero_gift_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:swear_brotherhood_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:sworn_brother_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
@@ -5,7 +5,7 @@ import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.ProvinceDistances
import net.eagle0.eagle.library.util.LegacyProvinceDistances
object FactionLeaderProvinceRanker {
private def factionLeaderProvinceOrdering(
@@ -23,7 +23,7 @@ object FactionLeaderProvinceRanker {
gameState: GameState,
fromProvinceId: ProvinceId
): Ordering[Province] = Ordering.by((p: Province) =>
ProvinceDistances.distanceThroughFriendliesOption(
LegacyProvinceDistances.distanceThroughFriendliesOption(
fromProvinceId,
p.id,
factionId,
@@ -5,7 +5,7 @@ import net.eagle0.eagle.api.available_command.{MarchAvailableCommand, MarchComma
import net.eagle0.eagle.api.selected_command.MarchSelectedCommand
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.util.{CommandSelection, LegacyBattalionUtils, ProvinceDistances}
import net.eagle0.eagle.library.util.{CommandSelection, LegacyBattalionUtils, LegacyProvinceDistances}
import net.eagle0.eagle.library.util.command_choice_helpers.ProvinceGoldSurplusCalculator.provinceGoldSurplus
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
@@ -23,7 +23,7 @@ object MarchTowardProvinceCommandChooser {
.map(dest => (opmc, gs.provinces(dest.provinceId)))
.flatMap {
case (opmc, p) =>
ProvinceDistances
LegacyProvinceDistances
.distanceThroughFriendliesOption(
destinationProvinceId,
p.id,
@@ -32,6 +32,7 @@ import net.eagle0.eagle.library.util.food_consumption.LegacyFoodConsumptionUtils
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.util.LegacyProvinceDistances
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.views.battalion_view.BattalionView
import net.eagle0.eagle.views.province_view.FullProvinceInfo
@@ -626,7 +627,7 @@ object MidGameAIClient {
RandomState(
SeekMoreLeadersCommandChooser.maybeSeekMoreLeadersCommand(
actingFactionId,
gameState,
GameStateConverter.fromProto(gameState),
availableCommands
),
functionalRandom
@@ -904,7 +905,7 @@ object MidGameAIClient {
val furthestOrigin = candidateCommands.flatMap { opmc =>
// distance to the faction leader province we're closest to
factionLeaderProvincesNeedingHeroes.flatMap { flp =>
ProvinceDistances
LegacyProvinceDistances
.distanceThroughFriendliesOption(
p1 = flp.id,
p2 = opmc.originProvinceId,
@@ -4,8 +4,8 @@ import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.{AvailableCommand, MarchAvailableCommand, MarchCommandFromOneProvince}
import net.eagle0.eagle.internal.faction.Faction
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.util.{LegacyProvinceDistances, ProvinceDistances}
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.library.util.ProvinceDistances
import net.eagle0.eagle.library.util.ProvinceDistances.ProvinceAndDistance
object MoveLeaderToBetterProvinceCommandChooser {
@@ -53,7 +53,7 @@ object MoveLeaderToBetterProvinceCommandChooser {
mcfop.originProvinceId,
gs
).flatMap { desiredPid =>
ProvinceDistances
LegacyProvinceDistances
.closestProvinceThroughFriendliesOption(
desiredPid,
mcfop.availableDestinationProvinces.map(_.provinceId).toVector,
@@ -4,9 +4,6 @@ import net.eagle0.common.MoreOption
import net.eagle0.eagle.api.available_command.{AvailableCommand, MarchAvailableCommand}
import net.eagle0.eagle.api.selected_command.MarchSelectedCommand
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.hero.Hero
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.settings.{LoyaltyGainFromFeast, MinimumLoyaltyForSwearBrotherhood}
import net.eagle0.eagle.library.util.{CommandSelection, ProvinceDistances}
import net.eagle0.eagle.library.util.command_choice_helpers.{
@@ -15,26 +12,30 @@ import net.eagle0.eagle.library.util.command_choice_helpers.{
SwearBrotherhoodCommandSelector,
SwornBrotherChooser
}
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.hero.HeroUtils
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.FactionId
/** Protoless version of SeekMoreLeadersCommandChooser. */
object SeekMoreLeadersCommandChooser {
private def needsMoreLeaders(
actingFactionId: FactionId,
gameState: GameState
): Boolean =
gameState.factions(actingFactionId).leaders.size < SwornBrotherChooser
gameState.factions(actingFactionId).leaderIds.size < SwornBrotherChooser
.desiredNumberOfLeaders(
LegacyFactionUtils.provinceCount(actingFactionId, gameState)
FactionUtils.provinces(actingFactionId, gameState.provinces.values.toVector).size
)
case class Candidate(hero: Hero, province: Province)
case class Candidate(hero: HeroT, province: ProvinceT)
private def maybeMoveTowardDestination(
command: MarchAvailableCommand,
hero: Hero,
startingProvince: Province,
factionHeadProvince: Province,
hero: HeroT,
startingProvince: ProvinceT,
factionHeadProvince: ProvinceT,
gameState: GameState
): Option[CommandSelection] = {
val opmc = command.oneProvinceCommands
@@ -50,7 +51,7 @@ object SeekMoreLeadersCommandChooser {
factionHeadProvince.id,
p.id,
factionHeadProvince.rulingFactionId.get,
gameState
gameState.provinces
)
.map(distance => (opmc, p, distance))
}
@@ -83,23 +84,24 @@ object SeekMoreLeadersCommandChooser {
actingFactionId: FactionId,
gameState: GameState,
acs: Vector[AvailableCommand]
): Option[CommandSelection] =
LegacyFactionUtils
.provinces(actingFactionId, gameState)
): Option[CommandSelection] = {
val provincesVector = gameState.provinces.values.toVector
FactionUtils
.provinces(actingFactionId, provincesVector)
.find(
_.rulingFactionHeroIds
.contains(gameState.factions(actingFactionId).factionHeadId)
)
.flatMap { provinceWithFactionHead =>
val candidates =
LegacyFactionUtils
.provinces(actingFactionId, gameState)
FactionUtils
.provinces(actingFactionId, provincesVector)
.filter(_.rulingFactionHeroIds.length > 1)
.flatMap { province =>
SwornBrotherChooser
.bestChoiceProto(
.bestChoice(
province.rulingFactionHeroIds.map(gameState.heroes),
gameState.factions(actingFactionId).leaders.toVector
gameState.factions(actingFactionId).leaderIds
)
.map(hero => Candidate(hero, province))
}
@@ -117,7 +119,7 @@ object SeekMoreLeadersCommandChooser {
}
marchableCandidates
.maxByOption(x => LegacyHeroUtils.power(x._2.hero))
.maxByOption(x => HeroUtils.power(x._2.hero))
.flatMap {
case (command, candidate) =>
maybeMoveTowardDestination(
@@ -129,6 +131,7 @@ object SeekMoreLeadersCommandChooser {
)
}
}
}
def maybeSeekMoreLeadersCommand(
actingFactionId: FactionId,
@@ -142,9 +145,9 @@ object SeekMoreLeadersCommandChooser {
MoreOption
.flatWhen(needsMoreLeaders(actingFactionId, gameState)) {
// First try a hero that's already here
val desiredHero = SwornBrotherChooser.bestChoiceProto(
val desiredHero = SwornBrotherChooser.bestChoice(
leaderProvince.rulingFactionHeroIds.map(gameState.heroes),
faction.leaders.toVector
faction.leaderIds
)
desiredHero.flatMap { hero =>
@@ -160,7 +163,6 @@ object SeekMoreLeadersCommandChooser {
) {
CommandChoiceHelpers.chosenFeastCommandIfAvailable(
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = acs,
reason = s"want to make hero ${hero.id} a leader"
)
@@ -1,18 +1,5 @@
load("@rules_scala//scala:scala.bzl", "scala_library")
scala_library(
name = "action_pkg",
srcs = ["package.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/library:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
],
runtime_deps = [
"@maven//:com_thesamet_scalapb_lenses_3",
],
deps = ["//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto"],
)
scala_library(
name = "check_for_faction_changes_action",
srcs = ["CheckForFactionChangesAction.scala"],
@@ -89,7 +89,7 @@ case class PerformVassalCommandsPhaseAction(
commandOptions.collectFirst {
case ac: RestAvailableCommand =>
ac
}.isDefined && shouldRest(heroes)
}.isDefined && shouldRestProto(heroes)
) {
chosenRestCommand(actingFactionId, gsProto, commandOptions, reason)
}
@@ -1,10 +0,0 @@
package net.eagle0.eagle.library.actions.impl
import net.eagle0.eagle.internal.action_result.ActionResult
import scalapb.lenses
import scalapb.lenses.Lens
package object action {
type ActionResultProtoUpdater =
Lens[ActionResult, ActionResult] => lenses.Mutation[ActionResult]
}
@@ -274,10 +274,24 @@ scala_library(
"//src/test/scala/net/eagle0/eagle/library/util:__pkg__",
],
deps = [
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
scala_library(
name = "legacy_province_distances",
srcs = ["LegacyProvinceDistances.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/util:__pkg__",
],
deps = [
":province_distances",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
@@ -0,0 +1,59 @@
package net.eagle0.eagle.library.util
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.ProvinceDistances.{closestProvinceOption, distance, ProvinceAndDistance}
/** Legacy proto-based province distance utilities. Use ProvinceDistances for protoless code. */
object LegacyProvinceDistances {
def distanceThroughFriendliesOption(
p1: ProvinceId,
p2: ProvinceId,
fid: FactionId,
gs: GameState
): Option[Int] = distance(p1, p2, LegacyFactionUtils.ownedNeighbors(gs, fid))
def closestProvinceThroughFriendliesOption(
to: ProvinceId,
candidates: Vector[ProvinceId],
fid: FactionId,
gs: GameState
): Option[ProvinceAndDistance] =
closestProvinceOption(
to,
candidates.filter(pid => gs.provinces(pid).rulingFactionId.contains(fid)),
LegacyFactionUtils.ownedNeighbors(gs, fid)
)
def closestNeighborToFaction(
startingProvinceId: ProvinceId,
factionId: FactionId,
provinces: Map[ProvinceId, Province]
): Option[ProvinceId] = {
val factionProvinceIds = provinces.values
.filter(_.rulingFactionId.contains(factionId))
.map(_.id)
.toVector
if factionProvinceIds.isEmpty then None
else
provinces(startingProvinceId).neighbors
.map(_.provinceId)
.toVector
.minByOption { neighborPid =>
closestProvinceOption(
to = neighborPid,
candidates = factionProvinceIds,
eligibleNeighbors = pid =>
provinces
.get(pid)
.map(_.neighbors.map(_.provinceId).toVector)
.getOrElse(Vector())
).map(_.distance).getOrElse(Int.MaxValue)
}
end if
}
}
@@ -3,9 +3,7 @@ package net.eagle0.eagle.library.util
import scala.annotation.tailrec
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.model.state.province.ProvinceT
object ProvinceDistances {
@@ -14,8 +12,8 @@ object ProvinceDistances {
p1: ProvinceId,
p2: ProvinceId,
fid: FactionId,
gs: GameState
): Option[Int] = distance(p1, p2, LegacyFactionUtils.ownedNeighbors(gs, fid))
provinces: Map[ProvinceId, ProvinceT]
): Option[Int] = distance(p1, p2, FactionUtils.ownedNeighbors(provinces, fid))
def distance(
p1: ProvinceId,
@@ -34,18 +32,6 @@ object ProvinceDistances {
else go(Vector(Set(p1)))
}
def closestProvinceThroughFriendliesOption(
to: ProvinceId,
candidates: Vector[ProvinceId],
fid: FactionId,
gs: GameState
): Option[ProvinceAndDistance] =
closestProvinceOption(
to,
candidates.filter(pid => gs.provinces(pid).rulingFactionId.contains(fid)),
LegacyFactionUtils.ownedNeighbors(gs, fid)
)
def closestProvinceOption(
to: ProvinceId,
candidates: Vector[ProvinceId],
@@ -80,40 +66,11 @@ object ProvinceDistances {
}.toMap.get
}
def closestNeighborToFaction(
startingProvinceId: ProvinceId,
factionId: FactionId,
provinces: Map[ProvinceId, Province]
): Option[ProvinceId] = {
val factionProvinceIds = provinces.values
.filter(_.rulingFactionId.contains(factionId))
.map(_.id)
.toVector
if factionProvinceIds.isEmpty then None
else
provinces(startingProvinceId).neighbors
.map(_.provinceId)
.toVector
.minByOption { neighborPid =>
closestProvinceOption(
to = neighborPid,
candidates = factionProvinceIds,
eligibleNeighbors = pid =>
provinces
.get(pid)
.map(_.neighbors.map(_.provinceId).toVector)
.getOrElse(Vector())
).map(_.distance).getOrElse(Int.MaxValue)
}
end if
}
def closestNeighborToFaction(
startingProvinceId: ProvinceId,
factionId: FactionId,
provinces: Map[ProvinceId, ProvinceT]
)(using DummyImplicit): Option[ProvinceId] = {
): Option[ProvinceId] = {
val factionProvinceIds = provinces.values
.filter(_.rulingFactionId.contains(factionId))
.map(_.id)
@@ -209,10 +209,12 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util:beast_utils",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:incoming_army_utils",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
"//src/main/scala/net/eagle0/eagle/library/util/battalion_type_finder:legacy_battalion_type_finder",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/food_consumption:legacy_food_consumption_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_converter",
@@ -467,12 +469,10 @@ scala_library(
"//src/test/scala/net/eagle0/eagle/library/util/command_choice_helpers:__pkg__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:hero_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:ai_minimum_charisma_for_sworn_brother",
"//src/main/scala/net/eagle0/eagle/library/settings:ai_minimum_constitution_for_sworn_brother",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
],
)
@@ -41,7 +41,7 @@ import net.eagle0.eagle.library.settings.{
VassalMinimumFoodToSendSupplies,
VassalMinimumGoldToSendSupplies
}
import net.eagle0.eagle.library.util.{CommandSelection, IncomingArmyUtils, ProvinceDistances}
import net.eagle0.eagle.library.util.{CommandSelection, IncomingArmyUtils, LegacyProvinceDistances, ProvinceDistances}
import net.eagle0.eagle.library.util.battalion_type_finder.LegacyBattalionTypeFinder
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.randomSelectionForType
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.*
@@ -51,12 +51,13 @@ import net.eagle0.eagle.library.util.command_choice_helpers.ProvinceGoldSurplusC
}
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.food_consumption.LegacyFoodConsumptionUtils.foodConsumptionMonthsToHold
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.library.util.hero.{HeroUtils, LegacyHeroUtils}
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.proto_converters.hero.HeroConverter
import net.eagle0.eagle.model.proto_converters.BattalionConverter
import net.eagle0.eagle.model.proto_converters.BattalionTypeConverter
import net.eagle0.eagle.model.state.hero.HeroT
object CommandChoiceHelpers {
import AvailableCommandSelector.{flatSelectionForType, selectionForType}
@@ -539,7 +540,7 @@ object CommandChoiceHelpers {
reason = reason
).filter {
case CommandSelection(_, _, ac, _, _) =>
shouldRest(
shouldRestProto(
gs.provinces(
ac.asInstanceOf[RestAvailableCommand].actingProvinceId
).rulingFactionHeroIds
@@ -547,7 +548,16 @@ object CommandChoiceHelpers {
)
}
def shouldRest(heroes: Iterable[Hero]): Boolean =
/** Protoless version */
def shouldRest(heroes: Iterable[HeroT]): Boolean =
if heroes
.map(HeroUtils.fatigue)
.min >= VassalCommandsMaxFatigueLevelBeforeResting.doubleValue
then true
else false
/** Legacy proto version */
def shouldRestProto(heroes: Iterable[Hero]): Boolean =
if heroes
.map(LegacyHeroUtils.fatigue)
.min >= VassalCommandsMaxFatigueLevelBeforeResting.doubleValue
@@ -1030,7 +1040,7 @@ object CommandChoiceHelpers {
}
.flatMap {
case (leaderId, pid) =>
ProvinceDistances
LegacyProvinceDistances
.distanceThroughFriendliesOption(
p1 = actingProvinceId,
p2 = pid,
@@ -1135,7 +1145,6 @@ object CommandChoiceHelpers {
def chosenFeastCommandIfAvailable(
actingFactionId: FactionId,
gameState: GameState,
availableCommands: Vector[AvailableCommand],
reason: String
): Option[CommandSelection] =
@@ -1,14 +1,12 @@
package net.eagle0.eagle.library.util.command_choice_helpers
import net.eagle0.eagle.internal.hero.Hero
import net.eagle0.eagle.library.settings.{AiMinimumCharismaForSwornBrother, AiMinimumConstitutionForSwornBrother}
import net.eagle0.eagle.library.util.hero.{HeroUtils, LegacyHeroUtils}
import net.eagle0.eagle.library.util.hero.HeroUtils
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.HeroId
object SwornBrotherChooser {
/** Protoless version */
def bestChoice(
heroes: Iterable[HeroT],
leaders: Vector[HeroId]
@@ -19,19 +17,6 @@ object SwornBrotherChooser {
.filterNot(h => leaders.contains(h.id))
.maxByOption(HeroUtils.power)
/** Legacy proto version for backward compatibility */
def bestChoiceProto(
heroes: Iterable[Hero],
leaders: Vector[HeroId]
): Option[Hero] =
heroes
.filter(_.charisma >= AiMinimumCharismaForSwornBrother.doubleValue)
.filter(
_.constitution >= AiMinimumConstitutionForSwornBrother.doubleValue
)
.filterNot(h => leaders.contains(h.id))
.maxByOption(LegacyHeroUtils.power)
def desiredNumberOfLeaders(provinceCount: Int): Int =
if provinceCount < 3 then 1
else if provinceCount < 6 then 2
@@ -1,6 +1,6 @@
package net.eagle0.eagle.library.util.faction_utils
import net.eagle0.eagle.{FactionId, HeroId}
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.library.settings.{MinSupportForTaxes, PrestigePerSupportedProvince}
import net.eagle0.eagle.model.state.faction.{FactionRelationship, FactionT}
import net.eagle0.eagle.model.state.faction.FactionRelationship.RelationshipLevel.Ally
@@ -124,6 +124,16 @@ object FactionUtils {
provinces
.filter(_.rulingFactionId.contains(factionId))
def ownedNeighbors(
provinces: Map[ProvinceId, ProvinceT],
fid: FactionId
): ProvinceId => Vector[ProvinceId] =
pid =>
provinces(pid).neighbors
.map(_.provinceId)
.filter(n => provinces(n).rulingFactionId.contains(fid))
.toVector
def prestige(
faction: FactionT,
allProvinces: Vector[ProvinceT]
@@ -9,12 +9,10 @@ scala_library(
exports = [
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/action:action_pkg",
],
deps = [
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/action:action_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:max_desired_truce_count_for_quest",
"//src/main/scala/net/eagle0/eagle/library/settings:min_desired_new_truce_count_for_quest",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
@@ -33,13 +31,11 @@ scala_library(
exports = [
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/action:action_pkg",
],
deps = [
":truce_count_quest_creation",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/action:action_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:alms_province_count_exponent",
"//src/main/scala/net/eagle0/eagle/library/settings:gift_province_count_exponent",
"//src/main/scala/net/eagle0/eagle/library/settings:max_desired_alms_to_province_given",
@@ -150,6 +150,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result:__subpackages__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state:__pkg__",
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/util:__subpackages__",
"//src/test/scala/net/eagle0/eagle/service:__subpackages__",
@@ -31,6 +31,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/proto_converters:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/faction:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/state/game_state:__pkg__",
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/util:__subpackages__",
],
@@ -6,6 +6,7 @@ scala_library(
visibility = [
"//src/main/scala/net/eagle0/eagle/library/actions:__subpackages__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/faction:__pkg__",
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
"//src/test/scala/net/eagle0/eagle/library/actions:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/util:__subpackages__",
],
@@ -50,6 +50,7 @@ scala_library(
":__subpackages__",
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/main/scala/net/eagle0/eagle/shardok_interface:__pkg__",
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
"//src/test/scala/net/eagle0/eagle/library/actions:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/util:__subpackages__",
],
@@ -9,6 +9,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero:__pkg__",
"//src/main/scala/net/eagle0/eagle/service/new_game_creation:__pkg__",
"//src/main/scala/net/eagle0/eagle/shardok_interface:__pkg__",
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
"//src/test/scala/net/eagle0/eagle/library:__pkg__",
"//src/test/scala/net/eagle0/eagle/library/actions:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/util:__subpackages__",
@@ -43,6 +43,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/state/game_state:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/state/province/concrete:__pkg__",
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/util:__subpackages__",
],
@@ -9,6 +9,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:__subpackages__",
"//src/main/scala/net/eagle0/eagle/library/util/province:__pkg__",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province:__pkg__",
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
"//src/test/scala/net/eagle0/eagle/library/actions:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/util:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/util/command_choice_helpers:__pkg__",
@@ -314,6 +314,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/service/persistence:s3_persister",
"//src/main/scala/net/eagle0/eagle/service/persistence:s3_utils",
"//src/main/scala/net/eagle0/eagle/service/persistence:save_directory",
"//src/main/scala/net/eagle0/eagle/service/persistence/credentials",
],
)
@@ -395,6 +396,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/service/persistence:s3_persister",
"//src/main/scala/net/eagle0/eagle/service/persistence:s3_utils",
"//src/main/scala/net/eagle0/eagle/service/persistence:save_directory",
"//src/main/scala/net/eagle0/eagle/service/persistence/credentials",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_update",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_update_receiver",
@@ -359,79 +359,86 @@ class EagleServiceImpl(
responseObserver: SyncResponseObserver
): Unit = {
lockAndDoWithUserName { userName =>
println(
s"got heartbeat from $userName. Client timestamp ${request.clientTimestamp}, server timestamp ${System.currentTimeMillis()}"
)
// Synchronize on gamesManager to ensure heartbeat response is sent AFTER
// any pending game updates have been queued. Without this, there's a race
// condition where the heartbeat response can interleave with game updates,
// causing clients to see a sync mismatch before receiving all updates.
// This particularly affects slower/remote connections (e.g., Windows clients).
gamesManager.synchronized {
println(
s"got heartbeat from $userName. Client timestamp ${request.clientTimestamp}, server timestamp ${System.currentTimeMillis()}"
)
// Verify sync status for each game the client reports
val gameSyncResults = request.gameSyncStatuses.flatMap { clientGameStatus =>
gamesManager.gameControllerInfos.get(clientGameStatus.gameId).flatMap { controllerInfo =>
val controller = controllerInfo.controller
controller.userNameToFactionId.get(userName).map { factionId =>
val serverUnfilteredCount = controller.engine.history.count
// Verify sync status for each game the client reports
val gameSyncResults = request.gameSyncStatuses.flatMap { clientGameStatus =>
gamesManager.gameControllerInfos.get(clientGameStatus.gameId).flatMap { controllerInfo =>
val controller = controllerInfo.controller
controller.userNameToFactionId.get(userName).map { factionId =>
val serverUnfilteredCount = controller.engine.history.count
// Check Shardok sync status
val shardokSyncResults = clientGameStatus.shardokSyncStatuses.map { clientShardokStatus =>
val serverShardokCount = controller.engine.history.shardokPlayerCount(
clientShardokStatus.shardokGameId,
factionId
)
ShardokSyncResult(
shardokGameId = clientShardokStatus.shardokGameId,
inSync = clientShardokStatus.filteredResultCount == serverShardokCount,
serverFilteredResultCount = serverShardokCount
// Check Shardok sync status
val shardokSyncResults = clientGameStatus.shardokSyncStatuses.map { clientShardokStatus =>
val serverShardokCount = controller.engine.history.shardokPlayerCount(
clientShardokStatus.shardokGameId,
factionId
)
ShardokSyncResult(
shardokGameId = clientShardokStatus.shardokGameId,
inSync = clientShardokStatus.filteredResultCount == serverShardokCount,
serverFilteredResultCount = serverShardokCount
)
}
GameSyncResult(
gameId = clientGameStatus.gameId,
eagleInSync = clientGameStatus.unfilteredResultCount == serverUnfilteredCount,
serverUnfilteredResultCount = serverUnfilteredCount,
shardokSyncResults = shardokSyncResults
)
}
GameSyncResult(
gameId = clientGameStatus.gameId,
eagleInSync = clientGameStatus.unfilteredResultCount == serverUnfilteredCount,
serverUnfilteredResultCount = serverUnfilteredCount,
shardokSyncResults = shardokSyncResults
)
}
}
}
// Only include sync results if there are mismatches (to reduce message size)
val mismatchedResults = gameSyncResults.filter { result =>
!result.eagleInSync || result.shardokSyncResults.exists(!_.inSync)
}
if mismatchedResults.nonEmpty then {
// Include both client and server counts for debugging
val mismatchDetails = mismatchedResults.zip(request.gameSyncStatuses).map {
case (result, clientStatus) =>
val eagleDetail =
if !result.eagleInSync then
s"eagle: client=${clientStatus.unfilteredResultCount} server=${result.serverUnfilteredResultCount}"
else "eagle: in_sync"
val shardokDetails = result.shardokSyncResults.filter(!_.inSync).map { sr =>
val clientCount = clientStatus.shardokSyncStatuses
.find(_.shardokGameId == sr.shardokGameId)
.map(_.filteredResultCount)
.getOrElse(-1)
s"shardok[${sr.shardokGameId}]: client=$clientCount server=${sr.serverFilteredResultCount}"
}
s"game ${result.gameId}: $eagleDetail${
if shardokDetails.nonEmpty then ", " + shardokDetails.mkString(", ") else ""
}"
// Only include sync results if there are mismatches (to reduce message size)
val mismatchedResults = gameSyncResults.filter { result =>
!result.eagleInSync || result.shardokSyncResults.exists(!_.inSync)
}
println(s"[HEARTBEAT] Detected sync mismatches for $userName: ${mismatchDetails.mkString("; ")}")
}
val _ = responseObserver.onNext(
UpdateStreamResponse(
responseDetails = UpdateStreamResponse.ResponseDetails.HeartbeatResponse(
HeartbeatResponse(
serverTimestamp = System.currentTimeMillis(),
gameSyncResults = mismatchedResults
if mismatchedResults.nonEmpty then {
// Include both client and server counts for debugging
val mismatchDetails = mismatchedResults.zip(request.gameSyncStatuses).map {
case (result, clientStatus) =>
val eagleDetail =
if !result.eagleInSync then
s"eagle: client=${clientStatus.unfilteredResultCount} server=${result.serverUnfilteredResultCount}"
else "eagle: in_sync"
val shardokDetails = result.shardokSyncResults.filter(!_.inSync).map { sr =>
val clientCount = clientStatus.shardokSyncStatuses
.find(_.shardokGameId == sr.shardokGameId)
.map(_.filteredResultCount)
.getOrElse(-1)
s"shardok[${sr.shardokGameId}]: client=$clientCount server=${sr.serverFilteredResultCount}"
}
s"game ${result.gameId}: $eagleDetail${
if shardokDetails.nonEmpty then ", " + shardokDetails.mkString(", ") else ""
}"
}
println(s"[HEARTBEAT] Detected sync mismatches for $userName: ${mismatchDetails.mkString("; ")}")
}
val _ = responseObserver.onNext(
UpdateStreamResponse(
responseDetails = UpdateStreamResponse.ResponseDetails.HeartbeatResponse(
HeartbeatResponse(
serverTimestamp = System.currentTimeMillis(),
gameSyncResults = mismatchedResults
)
)
)
)
)
}
}
()
}
@@ -8,11 +8,10 @@ import net.eagle0.eagle.service.persistence.{
S3Utils,
SaveDirectory
}
import net.eagle0.eagle.service.persistence.credentials.S3Credentials
import net.eagle0.eagle.GameId
object LocalGamePersisterCreation extends GamePersisterCreation {
private val includeS3 = false
private def localFilePersister(gameId: GameId) = LocalFilePersister(
SaveDirectory.saveDirectoryForGame(gameId)
)
@@ -20,7 +19,7 @@ object LocalGamePersisterCreation extends GamePersisterCreation {
S3Persister(S3Utils.mainBucketName, S3Utils.makeS3Prefix(gameId))
def persisterForGame(gameId: GameId): Persister =
if includeS3 then
if S3Credentials.isEnabled then
CompoundPersister(
Vector(localFilePersister(gameId), s3Persister(gameId))
)
@@ -12,6 +12,7 @@ import net.eagle0.common.shardok_internal_interface.ShardokInternalInterfaceGrpc
import net.eagle0.common.shardok_internal_interface.ShardokInternalInterfaceGrpc.ShardokInternalInterfaceStub
import net.eagle0.eagle.service.new_game_creation.FixedNewGameCreation
import net.eagle0.eagle.service.persistence.{CompoundPersister, LocalFilePersister, S3Persister, S3Utils, SaveDirectory}
import net.eagle0.eagle.service.persistence.credentials.S3Credentials
import net.eagle0.eagle.shardok_interface.ShardokInterfaceGrpcClient
import net.eagle0.shardok.common.hex_map.HexMap
@@ -59,16 +60,19 @@ object ServerSetupHelpers {
.get
val localPersister = LocalFilePersister(SaveDirectory.saveDirectory)
val s3Persister =
S3Persister(S3Utils.mainBucketName, S3Utils.runningGamesKeyPrefix)
val _ = s3Persister
val persisters = if S3Credentials.isEnabled then {
val s3Persister =
S3Persister(S3Utils.mainBucketName, S3Utils.runningGamesKeyPrefix)
Vector(localPersister, s3Persister)
} else {
Vector(localPersister)
}
GamesManager(
shardokInternalInterface = shardokInternalInterface,
randomGenerator = new SecureRandom,
persister = CompoundPersister(
Vector(localPersister)
),
persister = CompoundPersister(persisters),
gameCreation = FixedNewGameCreation,
gamePersisterCreation = LocalGamePersisterCreation,
gptModelName = gptModelName,
@@ -85,6 +85,10 @@ object GameController {
case x: StatusRuntimeException if x.getStatus.getCode == Status.Code.CANCELLED =>
None
case _: IllegalStateException =>
// Stream is already completed (client disconnected)
None
case x: StatusRuntimeException =>
SimpleTimedLogger.printLogger.logLine(
s"fid ${client.factionId}: Caught exception $x"
@@ -196,6 +200,10 @@ object GameController {
case x: StatusRuntimeException if x.getStatus.getCode == Status.Code.CANCELLED =>
None
case _: IllegalStateException =>
// Stream is already completed (client disconnected)
None
case x: StatusRuntimeException =>
SimpleTimedLogger.printLogger.logLine(
s"fid ${client.factionId}: Caught exception $x"
@@ -24,18 +24,19 @@ object S3Utils {
private def syncClient() = S3Client
.builder()
.endpointOverride(URI.create("https://sfo3.digitaloceanspaces.com"))
.endpointOverride(URI.create(S3Credentials.endpoint))
.region(Region.US_EAST_1)
.credentialsProvider(S3Credentials.credentialsProvider)
.build
private val transferManager: S3TransferManager =
private lazy val transferManager: S3TransferManager =
S3TransferManager.builder
.s3Client(
S3AsyncClient
.builder()
.endpointOverride(URI.create("https://sfo3.digitaloceanspaces.com"))
.endpointOverride(URI.create(S3Credentials.endpoint))
.region(Region.US_EAST_1)
.credentialsProvider(S3Credentials.credentialsProvider)
.build()
)
.build
@@ -4,7 +4,7 @@ scala_library(
name = "credentials",
srcs = ["S3Credentials.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/service/persistence:__pkg__",
"//src/main/scala/net/eagle0/eagle/service:__subpackages__",
],
deps = [
"@maven//:software_amazon_awssdk_auth",
@@ -7,6 +7,15 @@ import scala.util.{Try, Using}
import software.amazon.awssdk.auth.credentials.{AwsBasicCredentials, AwsCredentialsProvider, StaticCredentialsProvider}
/**
* Centralized S3/DO Spaces configuration.
*
* Environment variables:
* - EAGLE_ENABLE_S3: Set to "true" to enable S3 persistence
* - DO_SPACES_ENDPOINT: S3 endpoint URL (default: https://sfo3.digitaloceanspaces.com)
* - DO_SPACES_ACCESS_KEY: Access key (falls back to ~/.s3cfg)
* - DO_SPACES_SECRET_KEY: Secret key (falls back to ~/.s3cfg)
*/
object S3Credentials {
private def makeS3cfgMap(): Try[Map[String, String]] = {
val s3cfgFile = new File(System.getProperty("user.home"), ".s3cfg")
@@ -32,12 +41,54 @@ object S3Credentials {
}
}
private val s3cfgMap = makeS3cfgMap().get
/** Whether S3 persistence is enabled (EAGLE_ENABLE_S3=true and credentials available). */
val isEnabled: Boolean = {
val envValue = Option(System.getenv("EAGLE_ENABLE_S3"))
.map(_.toLowerCase)
.contains("true")
if envValue && !hasCredentials then {
System.err.println(
"WARNING: EAGLE_ENABLE_S3=true but no S3 credentials configured. S3 persistence disabled."
)
false
} else envValue
}
/** S3 endpoint URL. Defaults to DO Spaces SFO3 region. */
val endpoint: String = Option(System.getenv("DO_SPACES_ENDPOINT"))
.getOrElse("https://sfo3.digitaloceanspaces.com")
/** Check if S3 credentials are available (either from env vars or ~/.s3cfg). */
private def hasCredentials: Boolean = {
val hasEnvVars = Option(System.getenv("DO_SPACES_ACCESS_KEY")).isDefined &&
Option(System.getenv("DO_SPACES_SECRET_KEY")).isDefined
val hasS3cfg = {
val s3cfgFile = new File(System.getProperty("user.home"), ".s3cfg")
s3cfgFile.exists()
}
hasEnvVars || hasS3cfg
}
/** Lazily load credentials from environment variables or ~/.s3cfg file. */
lazy val credentialsProvider: AwsCredentialsProvider = {
val accessKey = Option(System.getenv("DO_SPACES_ACCESS_KEY"))
val secretKey = Option(System.getenv("DO_SPACES_SECRET_KEY"))
val credentials = (accessKey, secretKey) match {
case (Some(ak), Some(sk)) =>
AwsBasicCredentials.create(ak, sk)
case _ =>
// Fall back to ~/.s3cfg file
val s3cfgMap = makeS3cfgMap().get
AwsBasicCredentials.create(
s3cfgMap("access_key"),
s3cfgMap("secret_key")
)
}
private val credentials = AwsBasicCredentials.create(
s3cfgMap("access_key"),
s3cfgMap("secret_key")
)
val credentialsProvider: AwsCredentialsProvider =
StaticCredentialsProvider.create(credentials)
}
}
@@ -52,14 +52,8 @@ TEST_F(FixedActionPointDistancesTest, testTiming) {
const auto start = system_clock::now();
for (const HexMapW& hexMap : hexMaps) {
auto result = shardok::FixedActionPointDistances::Create(
hexMap,
0x1234,
0xABCD,
battalionType,
true,
5);
// The result contains both the APD and whether it was loaded from file
auto result = shardok::FixedActionPointDistances::Create(hexMap, battalionType, true, 5);
ASSERT_NE(result, nullptr);
}
const auto end = system_clock::now();
+10 -3
View File
@@ -112,8 +112,7 @@ scala_test(
srcs = ["SeekMoreLeadersCommandChooserTest.scala"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/common:round_phase_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/ai:seek_more_leaders_command_chooser",
"//src/main/scala/net/eagle0/eagle/library/settings:ai_minimum_charisma_for_sworn_brother",
@@ -121,6 +120,14 @@ scala_test(
"//src/main/scala/net/eagle0/eagle/library/settings:loyalty_gain_from_feast",
"//src/main/scala/net/eagle0/eagle/library/settings:minimum_loyalty_for_swear_brotherhood",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:idable",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/faction/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/hero/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/state/province/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/run_status",
],
)
@@ -1,5 +1,6 @@
package net.eagle0.eagle.ai
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.api.available_command.{
AvailableCommand,
AvailableDestinationProvince,
@@ -20,12 +21,6 @@ import net.eagle0.eagle.api.selected_command.{
}
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.common.improvement_type.ImprovementType
import net.eagle0.eagle.common.profession.Profession
import net.eagle0.eagle.common.round_phase.RoundPhase.PLAYER_COMMANDS
import net.eagle0.eagle.internal.faction.Faction
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.hero.Hero
import net.eagle0.eagle.internal.province.{Neighbor, Province}
import net.eagle0.eagle.library.settings.{
AiMinimumCharismaForSwornBrother,
AiMinimumConstitutionForSwornBrother,
@@ -33,14 +28,28 @@ import net.eagle0.eagle.library.settings.{
MinimumLoyaltyForSwearBrotherhood
}
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.library.util.IDable.{mapifyFactions, mapifyHeroes, mapifyProvinces}
import net.eagle0.eagle.model.state.faction.concrete.FactionC
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.{Gender, Profession}
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.province.{Neighbor, ProvinceT}
import net.eagle0.eagle.model.state.province.concrete.ProvinceC
import net.eagle0.eagle.model.state.run_status.RunStatus
import net.eagle0.eagle.model.state.RoundPhase
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.scalatest.BeforeAndAfterEach
import org.scalatest.Inside.inside
/** Tests for the protoless SeekMoreLeadersCommandChooser */
class SeekMoreLeadersCommandChooserTest extends AnyFlatSpec with Matchers with BeforeAndAfterEach {
private def mapifyProvinces(ps: ProvinceT*): Map[ProvinceId, ProvinceT] = ps.map(p => p.id -> p).toMap
private def mapifyHeroes(hs: HeroT*): Map[HeroId, HeroT] = hs.map(h => h.id -> h).toMap
private def mapifyFactions(fs: FactionT*): Map[FactionId, FactionT] = fs.map(f => f.id -> f).toMap
private val swearBrotherhoodAC = SwearBrotherhoodAvailableCommand(
actingProvinceId = 17,
availableHeroes = Vector(9, 12, 13).map(hid => HeroAndBackstory(heroId = hid))
@@ -63,30 +72,104 @@ class SeekMoreLeadersCommandChooserTest extends AnyFlatSpec with Matchers with B
private val tryToSwearBrotherhoodACs: Vector[AvailableCommand] =
Vector(heroGiftAC, feastAC, swearBrotherhoodAC)
private val swearBrotherhoodGameState: GameState = GameState(
currentPhase = PLAYER_COMMANDS,
provinces = mapifyProvinces(
Province(
id = 17,
rulingFactionId = Some(4),
rulingFactionHeroIds = Vector(1, 9, 12, 13)
),
Province(id = 20, rulingFactionId = Some(4)),
Province(id = 909, rulingFactionId = Some(4))
),
factions = mapifyFactions(Faction(id = 4, leaders = Vector(1), factionHeadId = 1)),
heroes = mapifyHeroes(
Hero(id = 1, charisma = 90, constitution = 90, factionId = Some(4)),
Hero(id = 9, charisma = 70, constitution = 70, factionId = Some(4)),
Hero(
id = 12,
charisma = 70,
constitution = 70,
profession = Profession.CHAMPION,
factionId = Some(4)
),
Hero(id = 13, charisma = 80, constitution = 80, factionId = Some(4))
private def makeHero(
id: Int,
charisma: Int = 50,
constitution: Int = 50,
profession: Profession = Profession.NoProfession,
loyalty: Double = 50.0,
factionId: Option[Int] = None
): HeroC = HeroC(
id = id,
factionId = factionId,
strength = 50,
agility = 50,
wisdom = 50,
constitution = constitution,
charisma = charisma,
vigor = constitution.toDouble,
loyalty = loyalty,
integrity = 50,
gregariousness = 50,
bravery = 50,
profession = profession,
pronounGender = Gender.Male
)
private def makeProvince(
id: Int,
rulingFactionId: Option[Int] = None,
rulingFactionHeroIds: Vector[Int] = Vector.empty,
neighbors: Vector[Neighbor] = Vector.empty
): ProvinceC = ProvinceC(
id = id,
rulingFactionId = rulingFactionId,
rulingFactionHeroIds = rulingFactionHeroIds,
neighbors = neighbors,
support = 50.0,
gold = 100,
food = 100
)
private def makeFaction(
id: Int,
factionHeadId: Int,
leaderIds: Vector[Int] = Vector.empty
): FactionC = FactionC(
id = id,
factionHeadId = factionHeadId,
name = s"Faction $id",
leaderIds = if leaderIds.isEmpty then Vector(factionHeadId) else leaderIds
)
private def makeGameState(
provinces: ProvinceC*
)(heroes: HeroC*)(factions: FactionC*): GameState =
GameState(
gameId = 1,
currentRoundId = 1,
currentPhase = RoundPhase.PlayerCommands,
currentDate = None,
actionResultCount = 0,
provinces = mapifyProvinces(provinces*),
heroes = mapifyHeroes(heroes*),
battalions = Map.empty,
destroyedBattalions = Map.empty,
factions = mapifyFactions(factions*),
factionCommandCounts = Map.empty,
killedHeroes = Map.empty,
destroyedFactions = Map.empty,
outstandingBattles = Vector.empty,
battleCounter = 0,
deferredNotifications = Vector.empty,
runStatus = RunStatus.Running,
victor = None,
battalionTypes = Vector.empty,
randomSeed = 0L,
chronicleEntries = Vector.empty
)
private val swearBrotherhoodGameState: GameState = makeGameState(
makeProvince(
id = 17,
rulingFactionId = Some(4),
rulingFactionHeroIds = Vector(1, 9, 12, 13)
),
makeProvince(id = 20, rulingFactionId = Some(4)),
makeProvince(id = 909, rulingFactionId = Some(4))
)(
makeHero(id = 1, charisma = 90, constitution = 90, factionId = Some(4)),
makeHero(id = 9, charisma = 70, constitution = 70, factionId = Some(4)),
makeHero(
id = 12,
charisma = 70,
constitution = 70,
profession = Profession.Champion,
factionId = Some(4)
),
makeHero(id = 13, charisma = 80, constitution = 80, factionId = Some(4))
)(
makeFaction(id = 4, factionHeadId = 1, leaderIds = Vector(1))
)
private val improveForTroopsAC: ImproveAvailableCommand =
@@ -126,7 +209,7 @@ class SeekMoreLeadersCommandChooserTest extends AnyFlatSpec with Matchers with B
inside(selection.selected) {
case SwearBrotherhoodSelectedCommand(
newBrotherHeroId,
_ /* uknownFieldSet */
_ /* unknownFieldSet */
) =>
newBrotherHeroId shouldBe 12
}
@@ -151,7 +234,7 @@ class SeekMoreLeadersCommandChooserTest extends AnyFlatSpec with Matchers with B
selection.available should not be swearBrotherhoodAC
selection.selected should not matchPattern {
case SwearBrotherhoodSelectedCommand(_, _ /* uknownFieldSet */ ) =>
case SwearBrotherhoodSelectedCommand(_, _ /* unknownFieldSet */ ) =>
}
}
@@ -164,8 +247,11 @@ class SeekMoreLeadersCommandChooserTest extends AnyFlatSpec with Matchers with B
)
)
val gsAlmostEnoughLoyalty = swearBrotherhoodGameState.update(
_.heroes(12).loyalty := 86
val gsAlmostEnoughLoyalty = swearBrotherhoodGameState.copy(
heroes = swearBrotherhoodGameState.heroes.updated(
12,
swearBrotherhoodGameState.heroes(12).asInstanceOf[HeroC].copy(loyalty = 86)
)
)
val selection = SeekMoreLeadersCommandChooser
@@ -178,7 +264,7 @@ class SeekMoreLeadersCommandChooserTest extends AnyFlatSpec with Matchers with B
selection.available shouldBe feastAC
selection.selected should matchPattern {
case FeastSelectedCommand(_ /* uknownFieldSet */ ) =>
case FeastSelectedCommand(_ /* unknownFieldSet */ ) =>
}
}
@@ -191,8 +277,11 @@ class SeekMoreLeadersCommandChooserTest extends AnyFlatSpec with Matchers with B
)
)
val gsFeastWontGiveEnough = swearBrotherhoodGameState.update(
_.heroes(12).loyalty := 84
val gsFeastWontGiveEnough = swearBrotherhoodGameState.copy(
heroes = swearBrotherhoodGameState.heroes.updated(
12,
swearBrotherhoodGameState.heroes(12).asInstanceOf[HeroC].copy(loyalty = 84)
)
)
val selection = SeekMoreLeadersCommandChooser
@@ -208,7 +297,7 @@ class SeekMoreLeadersCommandChooserTest extends AnyFlatSpec with Matchers with B
case HeroGiftSelectedCommand(
recipientHeroId,
amount,
_ /* uknownFieldSet */
_ /* unknownFieldSet */
) =>
recipientHeroId shouldBe 12
amount shouldBe 100
@@ -216,10 +305,11 @@ class SeekMoreLeadersCommandChooserTest extends AnyFlatSpec with Matchers with B
}
it should "do nothing if no available hero meets the minimums" in {
val crappyHeroesGS = swearBrotherhoodGameState.update(
_.heroes(12).charisma := 64,
_.heroes(9).constitution := 63,
_.heroes(13).charisma := 60
val crappyHeroesGS = swearBrotherhoodGameState.copy(
heroes = swearBrotherhoodGameState.heroes
.updated(12, swearBrotherhoodGameState.heroes(12).asInstanceOf[HeroC].copy(charisma = 64))
.updated(9, swearBrotherhoodGameState.heroes(9).asInstanceOf[HeroC].copy(constitution = 63))
.updated(13, swearBrotherhoodGameState.heroes(13).asInstanceOf[HeroC].copy(charisma = 60))
)
SeekMoreLeadersCommandChooser
@@ -240,8 +330,11 @@ class SeekMoreLeadersCommandChooserTest extends AnyFlatSpec with Matchers with B
}
it should "do nothing if we don't need any more heroes" in {
val gsWithFewerProvinces = swearBrotherhoodGameState.update(
_.provinces(20).optionalRulingFactionId := None
val gsWithFewerProvinces = swearBrotherhoodGameState.copy(
provinces = swearBrotherhoodGameState.provinces.updated(
20,
swearBrotherhoodGameState.provinces(20).asInstanceOf[ProvinceC].copy(rulingFactionId = None)
)
)
SeekMoreLeadersCommandChooser
@@ -253,38 +346,35 @@ class SeekMoreLeadersCommandChooserTest extends AnyFlatSpec with Matchers with B
}
it should "move a candidate hero if that is an option" in {
val gameStateWithCandidateInNeighbor: GameState = GameState(
currentPhase = PLAYER_COMMANDS,
provinces = mapifyProvinces(
Province(
id = 17,
rulingFactionId = Some(4),
rulingFactionHeroIds = Vector(1, 9, 13),
neighbors = Vector(Neighbor(provinceId = 20, startingPositionIndex = 0))
),
Province(
id = 20,
rulingFactionId = Some(4),
rulingFactionHeroIds = Vector(12, 15, 16),
neighbors = Vector(Neighbor(provinceId = 17, startingPositionIndex = 0))
),
Province(id = 909, rulingFactionId = Some(4))
val gameStateWithCandidateInNeighbor: GameState = makeGameState(
makeProvince(
id = 17,
rulingFactionId = Some(4),
rulingFactionHeroIds = Vector(1, 9, 13),
neighbors = Vector(Neighbor(provinceId = 20, startingPositionIndex = 0))
),
factions = mapifyFactions(Faction(id = 4, leaders = Vector(1), factionHeadId = 1)),
heroes = mapifyHeroes(
Hero(id = 1, charisma = 90, constitution = 90, factionId = Some(4)),
Hero(id = 9, charisma = 70, constitution = 35, factionId = Some(4)),
Hero(
id = 12,
charisma = 70,
constitution = 70,
profession = Profession.CHAMPION,
factionId = Some(4)
),
Hero(id = 13, charisma = 35, constitution = 80, factionId = Some(4)),
Hero(id = 15, charisma = 25, constitution = 80, factionId = Some(4)),
Hero(id = 16, charisma = 35, constitution = 80, factionId = Some(4))
)
makeProvince(
id = 20,
rulingFactionId = Some(4),
rulingFactionHeroIds = Vector(12, 15, 16),
neighbors = Vector(Neighbor(provinceId = 17, startingPositionIndex = 0))
),
makeProvince(id = 909, rulingFactionId = Some(4))
)(
makeHero(id = 1, charisma = 90, constitution = 90, factionId = Some(4)),
makeHero(id = 9, charisma = 70, constitution = 35, factionId = Some(4)),
makeHero(
id = 12,
charisma = 70,
constitution = 70,
profession = Profession.Champion,
factionId = Some(4)
),
makeHero(id = 13, charisma = 35, constitution = 80, factionId = Some(4)),
makeHero(id = 15, charisma = 25, constitution = 80, factionId = Some(4)),
makeHero(id = 16, charisma = 35, constitution = 80, factionId = Some(4))
)(
makeFaction(id = 4, factionHeadId = 1, leaderIds = Vector(1))
)
val marchAC = MarchAvailableCommand(
@@ -768,6 +768,7 @@ scala_test(
"//src/main/scala/net/eagle0/eagle/model/proto_converters:action_result_proto_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero/concrete",
"//src/test/scala/net/eagle0/common:proto_matchers",
"//src/test/scala/net/eagle0/eagle/library/actions/impl:action_impl_pkg",
"//src/test/scala/net/eagle0/eagle/library/util/command_choice_helpers:selected_command_matcher",
@@ -49,6 +49,7 @@ import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.library.util.IDable.{mapifyFactions, mapifyHeroes, mapifyProvinces}
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import org.scalamock.scalatest.MockFactory
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
@@ -313,25 +314,37 @@ class PerformVassalCommandsPhaseActionTest extends AnyFlatSpec with Matchers wit
VassalMinimumGoldToSendSupplies.setIntValue(100)
}
"shouldRest" should "return false if no one is tired" in {
CommandChoiceHelpers.shouldRest(
"shouldRestProto" should "return false if no one is tired" in {
CommandChoiceHelpers.shouldRestProto(
Vector(fullyRestedHero1, fullyRestedHero2)
) shouldBe false
}
it should "return false if only one hero is tired" in {
CommandChoiceHelpers.shouldRest(
CommandChoiceHelpers.shouldRestProto(
Vector(fullyRestedHero1, veryTiredHero1)
) shouldBe false
}
it should "return false if all heroes are a little tired" in {
CommandChoiceHelpers.shouldRest(Vector(slightlyTiredHero)) shouldBe false
CommandChoiceHelpers.shouldRestProto(Vector(slightlyTiredHero)) shouldBe false
}
it should "return true if all heroes are very tired" in {
CommandChoiceHelpers.shouldRestProto(
Vector(veryTiredHero1, veryTiredHero2)
) shouldBe true
}
"shouldRest (protoless)" should "return false if no one is tired" in {
CommandChoiceHelpers.shouldRest(
Vector(HeroC(id = 1, constitution = 100, vigor = 100), HeroC(id = 2, constitution = 100, vigor = 100))
) shouldBe false
}
it should "return true if all heroes are very tired" in {
CommandChoiceHelpers.shouldRest(
Vector(veryTiredHero1, veryTiredHero2)
Vector(HeroC(id = 1, constitution = 100, vigor = 50), HeroC(id = 2, constitution = 100, vigor = 50))
) shouldBe true
}
@@ -102,18 +102,26 @@ scala_test(
],
)
scala_test(
name = "legacy_province_distances_test",
srcs = ["LegacyProvinceDistancesTest.scala"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:province_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
],
)
scala_test(
name = "province_distances_test",
srcs = ["ProvinceDistancesTest.scala"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/common:date_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:faction_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:hero_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:province_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/state/province/concrete",
],
)
@@ -0,0 +1,266 @@
package net.eagle0.eagle.library.util
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.{Neighbor, Province}
import net.eagle0.eagle.library.util.ProvinceDistances.ProvinceAndDistance
import net.eagle0.eagle.ProvinceId
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
class LegacyProvinceDistancesTest extends AnyFlatSpec with Matchers {
behavior of "LegacyProvinceDistancesTest"
val factionId = 7
private def NeighborsFor(pids: Vector[ProvinceId]): Vector[Neighbor] =
pids.map(pid => Neighbor(provinceId = pid))
"closestProvinceExcludingHostilesOption" should "return the closer province if it's all friendlies" in {
val a = Province(
id = 1,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(2, 3))
)
val b = Province(
id = 2,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(1, 3, 4))
)
val c = Province(
id = 3,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(1, 2, 5))
)
val d = Province(
id = 4,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(2, 6))
)
val e = Province(
id = 5,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(3, 6))
)
val f = Province(
id = 6,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(4, 5))
)
val gs =
GameState(provinces = Vector(a, b, c, d, e, f).map(p => p.id -> p).toMap)
LegacyProvinceDistances.closestProvinceThroughFriendliesOption(
1,
Vector(4, 6),
factionId,
gs
) should contain(ProvinceAndDistance(4, 2))
}
it should "return the a farther province to keep path in friendlies" in {
val a = Province(
id = 1,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(2, 3))
)
val b = Province(
id = 2,
rulingFactionId = None,
neighbors = NeighborsFor(Vector(1, 3, 4))
)
val c = Province(
id = 3,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(1, 2, 5))
)
val d = Province(
id = 4,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(2, 6))
)
val e = Province(
id = 5,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(3, 6))
)
val f = Province(
id = 6,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(4, 5))
)
val gs =
GameState(provinces = Vector(a, b, c, d, e, f).map(p => p.id -> p).toMap)
LegacyProvinceDistances.closestProvinceThroughFriendliesOption(
1,
Vector(4, 6),
factionId,
gs
) should contain(ProvinceAndDistance(6, 3))
}
it should "return None if there is no route to any" in {
val a = Province(
id = 1,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(2, 3))
)
val b = Province(
id = 2,
rulingFactionId = None,
neighbors = NeighborsFor(Vector(1, 3, 4))
)
val c = Province(
id = 3,
rulingFactionId = None,
neighbors = NeighborsFor(Vector(1, 2, 5))
)
val d = Province(
id = 4,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(2, 6))
)
val e = Province(
id = 5,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(3, 6))
)
val f = Province(
id = 6,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(4, 5))
)
val gs =
GameState(provinces = Vector(a, b, c, d, e, f).map(p => p.id -> p).toMap)
LegacyProvinceDistances.closestProvinceThroughFriendliesOption(
1,
Vector(4, 6),
factionId,
gs
) shouldBe empty
}
"distanceExcludingHostiles" should "report the shortest distance if all friendly" in {
val a = Province(
id = 1,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(2, 3))
)
val b = Province(
id = 2,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(1, 3, 4))
)
val c = Province(
id = 3,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(1, 2, 5))
)
val d = Province(
id = 4,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(2, 5))
)
val e = Province(
id = 5,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(3, 4))
)
val gs =
GameState(provinces = Vector(a, b, c, d, e).map(p => p.id -> p).toMap)
LegacyProvinceDistances.distanceThroughFriendliesOption(
1,
4,
factionId,
gs
) should contain(
2
)
}
it should "report a longer distance around an unowned province" in {
val a = Province(
id = 1,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(2, 3))
)
val b = Province(
id = 2,
rulingFactionId = None,
neighbors = NeighborsFor(Vector(1, 3, 4))
)
val c = Province(
id = 3,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(1, 2, 5))
)
val d = Province(
id = 4,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(2, 5))
)
val e = Province(
id = 5,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(3, 4))
)
val gs =
GameState(provinces = Vector(a, b, c, d, e).map(p => p.id -> p).toMap)
LegacyProvinceDistances.distanceThroughFriendliesOption(
1,
4,
factionId,
gs
) should contain(
3
)
}
it should "return none if there is no route" in {
val a = Province(
id = 1,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(2, 3))
)
val b = Province(
id = 2,
rulingFactionId = None,
neighbors = NeighborsFor(Vector(1, 3, 4))
)
val c = Province(
id = 3,
rulingFactionId = None,
neighbors = NeighborsFor(Vector(1, 2, 5))
)
val d = Province(
id = 4,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(2, 5))
)
val e = Province(
id = 5,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(3, 4))
)
val gs =
GameState(provinces = Vector(a, b, c, d, e).map(p => p.id -> p).toMap)
LegacyProvinceDistances.distanceThroughFriendliesOption(
1,
4,
factionId,
gs
) shouldBe empty
}
}
@@ -1,12 +1,13 @@
package net.eagle0.eagle.library.util
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.{Neighbor, Province}
import net.eagle0.eagle.library.util.ProvinceDistances.ProvinceAndDistance
import net.eagle0.eagle.model.state.province.{Neighbor, ProvinceT}
import net.eagle0.eagle.model.state.province.concrete.ProvinceC
import net.eagle0.eagle.ProvinceId
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
/** Tests for the protoless ProvinceDistances */
class ProvinceDistancesTest extends AnyFlatSpec with Matchers {
behavior of "ProvinceDistancesTest"
@@ -14,253 +15,111 @@ class ProvinceDistancesTest extends AnyFlatSpec with Matchers {
val factionId = 7
private def NeighborsFor(pids: Vector[ProvinceId]): Vector[Neighbor] =
pids.map(pid => Neighbor(provinceId = pid))
pids.map(pid => Neighbor(provinceId = pid, startingPositionIndex = 0))
"closestProvinceExcludingHostilesOption" should "return the closer province if it's all friendlies" in {
val a = Province(
id = 1,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(2, 3))
)
val b = Province(
id = 2,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(1, 3, 4))
)
val c = Province(
id = 3,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(1, 2, 5))
)
val d = Province(
id = 4,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(2, 6))
)
val e = Province(
id = 5,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(3, 6))
)
val f = Province(
id = 6,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(4, 5))
)
private def makeProvince(
id: Int,
rulingFactionId: Option[Int],
neighbors: Vector[Neighbor]
): ProvinceC = ProvinceC(
id = id,
rulingFactionId = rulingFactionId,
neighbors = neighbors,
support = 50.0,
gold = 100,
food = 100
)
val gs =
GameState(provinces = Vector(a, b, c, d, e, f).map(p => p.id -> p).toMap)
private def mapifyProvinces(ps: ProvinceC*): Map[ProvinceId, ProvinceT] =
ps.map(p => p.id -> p).toMap
ProvinceDistances.closestProvinceThroughFriendliesOption(
1,
Vector(4, 6),
factionId,
gs
) should contain(ProvinceAndDistance(4, 2))
}
it should "return the a farther province to keep path in friendlies" in {
val a = Province(
id = 1,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(2, 3))
"distanceThroughFriendliesOption" should "report the shortest distance if all friendly" in {
val provinces = mapifyProvinces(
makeProvince(1, Some(factionId), NeighborsFor(Vector(2, 3))),
makeProvince(2, Some(factionId), NeighborsFor(Vector(1, 3, 4))),
makeProvince(3, Some(factionId), NeighborsFor(Vector(1, 2, 5))),
makeProvince(4, Some(factionId), NeighborsFor(Vector(2, 5))),
makeProvince(5, Some(factionId), NeighborsFor(Vector(3, 4)))
)
val b = Province(
id = 2,
rulingFactionId = None,
neighbors = NeighborsFor(Vector(1, 3, 4))
)
val c = Province(
id = 3,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(1, 2, 5))
)
val d = Province(
id = 4,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(2, 6))
)
val e = Province(
id = 5,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(3, 6))
)
val f = Province(
id = 6,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(4, 5))
)
val gs =
GameState(provinces = Vector(a, b, c, d, e, f).map(p => p.id -> p).toMap)
ProvinceDistances.closestProvinceThroughFriendliesOption(
1,
Vector(4, 6),
factionId,
gs
) should contain(ProvinceAndDistance(6, 3))
}
it should "return None if there is no route to any" in {
val a = Province(
id = 1,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(2, 3))
)
val b = Province(
id = 2,
rulingFactionId = None,
neighbors = NeighborsFor(Vector(1, 3, 4))
)
val c = Province(
id = 3,
rulingFactionId = None,
neighbors = NeighborsFor(Vector(1, 2, 5))
)
val d = Province(
id = 4,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(2, 6))
)
val e = Province(
id = 5,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(3, 6))
)
val f = Province(
id = 6,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(4, 5))
)
val gs =
GameState(provinces = Vector(a, b, c, d, e, f).map(p => p.id -> p).toMap)
ProvinceDistances.closestProvinceThroughFriendliesOption(
1,
Vector(4, 6),
factionId,
gs
) shouldBe empty
}
"distanceExcludingHostiles" should "report the shortest distance if all friendly" in {
val a = Province(
id = 1,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(2, 3))
)
val b = Province(
id = 2,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(1, 3, 4))
)
val c = Province(
id = 3,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(1, 2, 5))
)
val d = Province(
id = 4,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(2, 5))
)
val e = Province(
id = 5,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(3, 4))
)
val gs =
GameState(provinces = Vector(a, b, c, d, e).map(p => p.id -> p).toMap)
ProvinceDistances.distanceThroughFriendliesOption(
1,
4,
factionId,
gs
) should contain(
2
)
provinces
) should contain(2)
}
it should "report a longer distance around an unowned province" in {
val a = Province(
id = 1,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(2, 3))
val provinces = mapifyProvinces(
makeProvince(1, Some(factionId), NeighborsFor(Vector(2, 3))),
makeProvince(2, None, NeighborsFor(Vector(1, 3, 4))),
makeProvince(3, Some(factionId), NeighborsFor(Vector(1, 2, 5))),
makeProvince(4, Some(factionId), NeighborsFor(Vector(2, 5))),
makeProvince(5, Some(factionId), NeighborsFor(Vector(3, 4)))
)
val b = Province(
id = 2,
rulingFactionId = None,
neighbors = NeighborsFor(Vector(1, 3, 4))
)
val c = Province(
id = 3,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(1, 2, 5))
)
val d = Province(
id = 4,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(2, 5))
)
val e = Province(
id = 5,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(3, 4))
)
val gs =
GameState(provinces = Vector(a, b, c, d, e).map(p => p.id -> p).toMap)
ProvinceDistances.distanceThroughFriendliesOption(
1,
4,
factionId,
gs
) should contain(
3
)
provinces
) should contain(3)
}
it should "return none if there is no route" in {
val a = Province(
id = 1,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(2, 3))
val provinces = mapifyProvinces(
makeProvince(1, Some(factionId), NeighborsFor(Vector(2, 3))),
makeProvince(2, None, NeighborsFor(Vector(1, 3, 4))),
makeProvince(3, None, NeighborsFor(Vector(1, 2, 5))),
makeProvince(4, Some(factionId), NeighborsFor(Vector(2, 5))),
makeProvince(5, Some(factionId), NeighborsFor(Vector(3, 4)))
)
val b = Province(
id = 2,
rulingFactionId = None,
neighbors = NeighborsFor(Vector(1, 3, 4))
)
val c = Province(
id = 3,
rulingFactionId = None,
neighbors = NeighborsFor(Vector(1, 2, 5))
)
val d = Province(
id = 4,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(2, 5))
)
val e = Province(
id = 5,
rulingFactionId = Some(factionId),
neighbors = NeighborsFor(Vector(3, 4))
)
val gs =
GameState(provinces = Vector(a, b, c, d, e).map(p => p.id -> p).toMap)
ProvinceDistances.distanceThroughFriendliesOption(
1,
4,
factionId,
gs
provinces
) shouldBe empty
}
"closestProvinceOption" should "find the closest province" in {
val provinces = mapifyProvinces(
makeProvince(1, Some(factionId), NeighborsFor(Vector(2, 3))),
makeProvince(2, Some(factionId), NeighborsFor(Vector(1, 3, 4))),
makeProvince(3, Some(factionId), NeighborsFor(Vector(1, 2, 5))),
makeProvince(4, Some(factionId), NeighborsFor(Vector(2, 6))),
makeProvince(5, Some(factionId), NeighborsFor(Vector(3, 6))),
makeProvince(6, Some(factionId), NeighborsFor(Vector(4, 5)))
)
val eligibleNeighbors: ProvinceId => Vector[ProvinceId] =
pid => provinces.get(pid).map(_.neighbors.map(_.provinceId).toVector).getOrElse(Vector())
ProvinceDistances.closestProvinceOption(
to = 1,
candidates = Vector(4, 6),
eligibleNeighbors = eligibleNeighbors
) should contain(ProvinceAndDistance(4, 2))
}
"closestNeighborToFaction" should "find the neighbor closest to faction territory" in {
val provinces = mapifyProvinces(
makeProvince(1, None, NeighborsFor(Vector(2, 3))),
makeProvince(2, None, NeighborsFor(Vector(1, 4))),
makeProvince(3, None, NeighborsFor(Vector(1, 5))),
makeProvince(4, Some(factionId), NeighborsFor(Vector(2))),
makeProvince(5, None, NeighborsFor(Vector(3, 6))),
makeProvince(6, Some(factionId), NeighborsFor(Vector(5)))
)
// From province 1, neighbor 2 is closer to faction (distance 1 to province 4)
// than neighbor 3 (distance 2 to province 6)
ProvinceDistances.closestNeighborToFaction(
startingProvinceId = 1,
factionId = factionId,
provinces = provinces
) should contain(2)
}
}
@@ -227,13 +227,17 @@ scala_test(
deps = [
":selected_command_matcher",
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/common:round_phase_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:hero_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:idable",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:improve_command_selector",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/hero/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/model/state/province/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/run_status",
],
)
@@ -294,11 +298,11 @@ scala_test(
name = "sworn_brother_chooser_test",
srcs = ["SwornBrotherChooserTest.scala"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:ai_minimum_charisma_for_sworn_brother",
"//src/main/scala/net/eagle0/eagle/library/settings:ai_minimum_constitution_for_sworn_brother",
"//src/main/scala/net/eagle0/eagle/library/util:idable",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:sworn_brother_chooser",
"//src/test/scala/net/eagle0/common:proto_matchers",
"//src/main/scala/net/eagle0/eagle/model/state/hero:profession",
"//src/main/scala/net/eagle0/eagle/model/state/hero/concrete",
],
)
@@ -1,15 +1,16 @@
package net.eagle0.eagle.library.util.command_choice_helpers
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.api.available_command.{ImproveAvailableCommand, RestAvailableCommand}
import net.eagle0.eagle.api.selected_command.ImproveSelectedCommand
import net.eagle0.eagle.common.date.Date
import net.eagle0.eagle.common.improvement_type.ImprovementType.{AGRICULTURE, ECONOMY, INFRASTRUCTURE}
import net.eagle0.eagle.common.round_phase.RoundPhase
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.hero.Hero
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.util.IDable.mapifyHeroes
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.{Gender, HeroT}
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.province.concrete.ProvinceC
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.model.state.run_status.RunStatus
import net.eagle0.eagle.model.state.RoundPhase
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.scalatest.BeforeAndAfterEach
@@ -18,16 +19,67 @@ class ImproveCommandSelectorTest extends AnyFlatSpec with Matchers with BeforeAn
import SelectedCommandMatcher.*
private def mapifyProvinces(ps: ProvinceT*): Map[ProvinceId, ProvinceT] = ps.map(p => p.id -> p).toMap
private def mapifyHeroes(hs: HeroT*): Map[HeroId, HeroT] = hs.map(h => h.id -> h).toMap
private def makeHero(
id: Int,
factionId: Option[Int],
vigor: Double,
constitution: Int
): HeroC = HeroC(
id = id,
factionId = factionId,
strength = 50,
agility = 50,
wisdom = 50,
constitution = constitution,
charisma = 50,
vigor = vigor,
loyalty = 50,
integrity = 50,
gregariousness = 50,
bravery = 50,
pronounGender = Gender.Male
)
private def makeGameState(
provinces: ProvinceT*
)(heroes: HeroT*): GameState =
GameState(
gameId = 1,
currentRoundId = 1,
currentPhase = RoundPhase.PlayerCommands,
currentDate = None,
actionResultCount = 0,
provinces = mapifyProvinces(provinces*),
heroes = mapifyHeroes(heroes*),
battalions = Map.empty,
destroyedBattalions = Map.empty,
factions = Map.empty,
factionCommandCounts = Map.empty,
killedHeroes = Map.empty,
destroyedFactions = Map.empty,
outstandingBattles = Vector.empty,
battleCounter = 0,
deferredNotifications = Vector.empty,
runStatus = RunStatus.Running,
victor = None,
battalionTypes = Vector.empty,
randomSeed = 0L,
chronicleEntries = Vector.empty
)
private val improveCommand = ImproveAvailableCommand(
actingProvinceId = 7,
availableHeroIds = Vector(3, 1, 2, 4, 5),
availableTypes = Vector(INFRASTRUCTURE, AGRICULTURE, ECONOMY)
)
private val actingFactionId = 19
private val actingFactionId: FactionId = 19
private val actingProvince =
Province(
private val actingProvince: ProvinceC =
ProvinceC(
id = 7,
rulingFactionId = Some(actingFactionId),
rulingHeroId = Some(1),
@@ -40,64 +92,34 @@ class ImproveCommandSelectorTest extends AnyFlatSpec with Matchers with BeforeAn
support = 50
)
private val fullyRestedHero1 =
Hero(
factionId = Some(actingFactionId),
id = 1,
vigor = 90,
constitution = 90
)
private val fullyRestedHero2 =
Hero(
factionId = Some(actingFactionId),
id = 2,
vigor = 50,
constitution = 50
)
private val slightlyTiredHero =
Hero(
factionId = Some(actingFactionId),
id = 3,
vigor = 75,
constitution = 80
)
private val veryTiredHero1 =
Hero(
factionId = Some(actingFactionId),
id = 4,
vigor = 50,
constitution = 70
)
private val veryTiredHero2 =
Hero(
factionId = Some(actingFactionId),
id = 5,
vigor = 30,
constitution = 90
)
private val fullyRestedHero1: HeroC =
makeHero(id = 1, factionId = Some(actingFactionId), vigor = 90, constitution = 90)
private val fullyRestedHero2: HeroC =
makeHero(id = 2, factionId = Some(actingFactionId), vigor = 50, constitution = 50)
private val slightlyTiredHero: HeroC =
makeHero(id = 3, factionId = Some(actingFactionId), vigor = 75, constitution = 80)
private val veryTiredHero1: HeroC =
makeHero(id = 4, factionId = Some(actingFactionId), vigor = 50, constitution = 70)
private val veryTiredHero2: HeroC =
makeHero(id = 5, factionId = Some(actingFactionId), vigor = 30, constitution = 90)
private val gameState = GameState(
heroes = mapifyHeroes(
fullyRestedHero1,
fullyRestedHero2,
slightlyTiredHero,
veryTiredHero1,
veryTiredHero2
),
provinces = Map(7 -> actingProvince),
currentDate = Some(Date(year = 2983, month = 3)),
currentPhase = RoundPhase.PLAYER_COMMANDS
private val gameState: GameState = makeGameState(actingProvince)(
fullyRestedHero1,
fullyRestedHero2,
slightlyTiredHero,
veryTiredHero1,
veryTiredHero2
)
it should "choose Economy if that is lower than agriculture and infrastructure" in {
val lowerEconomyGameState = gameState.update(
_.provinces(actingProvince.id).economy := 20,
_.provinces(actingProvince.id).infrastructure := 30
val lowerEconomyProvince = actingProvince.copy(economy = 20, infrastructure = 30)
val lowerEconomyGameState = gameState.copy(
provinces = Map(actingProvince.id -> lowerEconomyProvince)
)
val selection = ImproveCommandSelector
.chosenImproveCommand(
actingFactionId = actingFactionId,
gameState = GameStateConverter.fromProto(lowerEconomyGameState),
gameState = lowerEconomyGameState,
availableCommands = Vector(improveCommand)
)
.get
@@ -114,7 +136,7 @@ class ImproveCommandSelectorTest extends AnyFlatSpec with Matchers with BeforeAn
ImproveCommandSelector
.chosenImproveCommand(
actingFactionId = actingFactionId,
gameState = GameStateConverter.fromProto(gameState),
gameState = gameState,
availableCommands = Vector(RestAvailableCommand(actingProvinceId = actingProvince.id))
)
.shouldBe(empty)
@@ -123,7 +145,7 @@ class ImproveCommandSelectorTest extends AnyFlatSpec with Matchers with BeforeAn
val selection = ImproveCommandSelector
.chosenImproveCommand(
actingFactionId = actingFactionId,
gameState = GameStateConverter.fromProto(gameState),
gameState = gameState,
availableCommands = Vector(improveCommand)
)
.get
@@ -1,9 +1,9 @@
package net.eagle0.eagle.library.util.command_choice_helpers
import net.eagle0.common.ProtoMatchers.equalProto
import net.eagle0.eagle.common.profession.Profession
import net.eagle0.eagle.internal.hero.Hero
import net.eagle0.eagle.library.settings.{AiMinimumCharismaForSwornBrother, AiMinimumConstitutionForSwornBrother}
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.hero.Profession
import net.eagle0.eagle.HeroId
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.scalatest.BeforeAndAfterEach
@@ -16,81 +16,62 @@ class SwornBrotherChooserTest extends AnyFlatSpec with Matchers with BeforeAndAf
"bestChoice" should "choose the max power" in {
val heroes = Vector(
Hero(id = 19, constitution = 85, charisma = 71),
Hero(id = 20, constitution = 100, charisma = 70),
Hero(id = 21, constitution = 65, charisma = 65)
HeroC(id = 19: HeroId, constitution = 85, charisma = 71),
HeroC(id = 20: HeroId, constitution = 100, charisma = 70),
HeroC(id = 21: HeroId, constitution = 65, charisma = 65)
)
SwornBrotherChooser.bestChoiceProto(heroes, Vector()).get should equalProto(
Hero(
id = 20,
constitution = 100,
charisma = 70
)
)
val result = SwornBrotherChooser.bestChoice(heroes, Vector())
result.map(_.id) shouldBe Some(20: HeroId)
result.map(_.constitution) shouldBe Some(100)
result.map(_.charisma) shouldBe Some(70)
}
it should "choose someone with lower stats if they have a profession" in {
val heroes = Vector(
Hero(id = 19, constitution = 85, charisma = 71),
Hero(id = 20, constitution = 100, charisma = 70),
Hero(
id = 21,
constitution = 65,
charisma = 65,
profession = Profession.MAGE
)
HeroC(id = 19: HeroId, constitution = 85, charisma = 71),
HeroC(id = 20: HeroId, constitution = 100, charisma = 70),
HeroC(id = 21: HeroId, constitution = 65, charisma = 65, profession = Profession.Mage)
)
SwornBrotherChooser.bestChoiceProto(heroes, Vector()).get should equalProto(
Hero(
id = 21,
constitution = 65,
charisma = 65,
profession = Profession.MAGE
)
)
val result = SwornBrotherChooser.bestChoice(heroes, Vector())
result.map(_.id) shouldBe Some(21: HeroId)
result.map(_.profession) shouldBe Some(Profession.Mage)
}
it should "not choose someone who is already a faction leader" in {
val heroes = Vector(
Hero(id = 19, constitution = 85, charisma = 71),
Hero(id = 20, constitution = 100, charisma = 70),
Hero(id = 21, constitution = 65, charisma = 65)
HeroC(id = 19: HeroId, constitution = 85, charisma = 71),
HeroC(id = 20: HeroId, constitution = 100, charisma = 70),
HeroC(id = 21: HeroId, constitution = 65, charisma = 65)
)
SwornBrotherChooser.bestChoiceProto(heroes, Vector(20)).get should equalProto(
Hero(
id = 19,
constitution = 85,
charisma = 71
)
)
val result = SwornBrotherChooser.bestChoice(heroes, Vector(20: HeroId))
result.map(_.id) shouldBe Some(19: HeroId)
result.map(_.constitution) shouldBe Some(85)
result.map(_.charisma) shouldBe Some(71)
}
it should "not choose someone under the minimum even if they're otherwise better" in {
val heroes = Vector(
Hero(id = 19, constitution = 85, charisma = 71),
Hero(id = 20, constitution = 100, charisma = 64),
Hero(id = 21, constitution = 65, charisma = 65)
HeroC(id = 19: HeroId, constitution = 85, charisma = 71),
HeroC(id = 20: HeroId, constitution = 100, charisma = 64),
HeroC(id = 21: HeroId, constitution = 65, charisma = 65)
)
SwornBrotherChooser.bestChoiceProto(heroes, Vector()).get should equalProto(
Hero(
id = 19,
constitution = 85,
charisma = 71
)
)
val result = SwornBrotherChooser.bestChoice(heroes, Vector())
result.map(_.id) shouldBe Some(19: HeroId)
result.map(_.constitution) shouldBe Some(85)
result.map(_.charisma) shouldBe Some(71)
}
it should "return None if no one has the minimum stats" in {
val heroes = Vector(
Hero(id = 19, constitution = 85, charisma = 64),
Hero(id = 20, constitution = 100, charisma = 64),
Hero(id = 21, constitution = 64, charisma = 65)
HeroC(id = 19: HeroId, constitution = 85, charisma = 64),
HeroC(id = 20: HeroId, constitution = 100, charisma = 64),
HeroC(id = 21: HeroId, constitution = 64, charisma = 65)
)
SwornBrotherChooser.bestChoiceProto(heroes, Vector()) shouldBe empty
SwornBrotherChooser.bestChoice(heroes, Vector()) shouldBe empty
}
}