mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 07:15:58 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0484a74fa | ||
|
|
672cb5cb20 | ||
|
|
816d2b6859 | ||
|
|
19b305829b | ||
|
|
3c382349e6 | ||
|
|
53314e7cb6 | ||
|
|
d4a5f48103 | ||
|
|
d6d1868fcb | ||
|
|
8c9d38c546 | ||
|
|
139494d937 | ||
|
|
5c52dd9006 | ||
|
|
d473e5a5d6 | ||
|
|
e9b980e6d0 | ||
|
|
8ba4268832 | ||
|
|
3a033ddf19 | ||
|
|
61196d05e7 | ||
|
|
e5a71bd3b4 | ||
|
|
e545d9c616 | ||
|
|
17e5135915 | ||
|
|
5c9623717d | ||
|
|
2ed1c5769e | ||
|
|
7a3b382623 | ||
|
|
d1e4ba4efe |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
+35
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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";
|
||||
|
||||
+3
-21
@@ -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,
|
||||
|
||||
+32
-86
@@ -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
|
||||
|
||||
+2
-16
@@ -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;
|
||||
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e9afdd2068b294a29993f07b379eb9c3
|
||||
+184
-87
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -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"],
|
||||
|
||||
+1
-1
@@ -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",
|
||||
],
|
||||
)
|
||||
|
||||
+15
-6
@@ -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
-16
@@ -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",
|
||||
|
||||
+57
-6
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-8
@@ -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();
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
+18
-5
@@ -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",
|
||||
],
|
||||
)
|
||||
|
||||
+84
-62
@@ -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
|
||||
|
||||
+34
-53
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user