Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 4.5 b021322de1 Remove cached images before pull to fix digest mismatch
Docker/containerd caches manifests locally which can conflict with
the registry when images are rebuilt with the same tag. Remove the
cached images before pulling to ensure a fresh pull.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-25 22:22:30 -08:00
63 changed files with 937 additions and 1680 deletions
+15 -165
View File
@@ -5,7 +5,6 @@ on:
branches: [ "main" ]
paths:
- 'src/main/cpp/**'
- 'src/main/go/**'
- 'src/main/scala/**'
- 'src/main/protobuf/**'
- 'src/main/resources/**'
@@ -35,15 +34,7 @@ jobs:
lfs: false
- name: Build Eagle Docker image
id: build-eagle
run: |
set -ex
bazel build --platforms=//:linux_x86_64 //ci:eagle_server_image
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
run: bazel build //ci:eagle_server_image
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
@@ -65,40 +56,6 @@ 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
@@ -110,13 +67,7 @@ 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 "$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"
$CRANE push bazel-bin/ci/eagle_server_image "$IMAGE_TAG"
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
@@ -278,90 +229,14 @@ 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, build-admin]
needs: [build-eagle, build-shardok]
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
@@ -382,54 +257,29 @@ jobs:
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
script_stop: true
envs: EAGLE_IMAGE,SHARDOK_IMAGE,ADMIN_IMAGE,OPENAI_API_KEY,GPT_MODEL_NAME,EAGLE_ENABLE_S3,DO_SPACES_ACCESS_KEY,DO_SPACES_SECRET_KEY
envs: EAGLE_IMAGE,SHARDOK_IMAGE
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, $ADMIN_IMAGE"
echo "Using images: $EAGLE_IMAGE, $SHARDOK_IMAGE"
# Use crane to pull images (handles OCI format correctly) then load into Docker
# This avoids digest mismatch from DO registry's OCI->Docker format conversion
echo "Installing crane..."
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
chmod +x crane
# 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
# 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
# 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; }
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
echo "Pulling Shardok image: $SHARDOK_IMAGE"
docker pull "${SHARDOK_IMAGE}" || { echo "ERROR: Failed to pull shardok image"; exit 1; }
# Also pull other compose images
docker pull nginx:alpine || true
-90
View File
@@ -1,90 +0,0 @@
# Deproto Migration Plan
This document tracks the migration from protobuf types to native Scala models inside the Eagle game engine.
## Architectural Decisions
1. **Keep proto for persistence**: Yes - protobuf is used for persisting game state
2. **Keep proto for Shardok communication**: Yes - protobuf is used for Eagle-Shardok gRPC communication
3. **Use Scala views inside the library**: Yes - use native Scala types like `ProvinceView`, `FactionView`, `HeroView`, etc. within the library code
## 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
+1 -9
View File
@@ -145,15 +145,7 @@ oci.pull(
platforms = ["linux/amd64"],
tag = "24.04",
)
# Base image for Admin Server (Alpine for lightweight Go binary)
oci.pull(
name = "alpine_linux",
image = "docker.io/library/alpine",
platforms = ["linux/amd64"],
tag = "3.21",
)
use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_17", "eclipse_temurin_17_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64")
use_repo(oci, "eclipse_temurin_17", "eclipse_temurin_17_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64")
#
# Java/Scala Dependencies
+2 -35
View File
@@ -1293,7 +1293,7 @@
"@@rules_oci~//oci:extensions.bzl%oci": {
"general": {
"bzlTransitiveDigest": "FaY+7xb13bB3hmxqwAWaGp3Tf3Q4Nfdlr+F38CP5mcg=",
"usagesDigest": "KXZUVR9ea29hTmhxC4+BG0pTXTijLpsFrDVraHG4OyU=",
"usagesDigest": "BuciKSozbpJMD9EP+j0RG5ZgrYMeDPsQyiOnLUni2V8=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
@@ -1360,37 +1360,6 @@
"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",
@@ -1527,9 +1496,7 @@
"eclipse_temurin_17",
"eclipse_temurin_17_linux_amd64",
"ubuntu_24_04",
"ubuntu_24_04_linux_amd64",
"alpine_linux",
"alpine_linux_linux_amd64"
"ubuntu_24_04_linux_amd64"
],
"explicitRootModuleDirectDevDeps": [],
"useAllRepos": "NO",
-41
View File
@@ -150,44 +150,3 @@ oci_push(
image = ":shardok_server_image",
repository = "registry.digitalocean.com/eagle0/shardok-server",
)
#
# Admin Server Docker Image (Go)
#
# Build: bazel build //ci:admin_server_image
# Load: bazel run //ci:admin_server_load
# Push: bazel run //ci:admin_server_push
#
# Package the Go admin binary (explicit Linux x86_64 target)
pkg_tar(
name = "admin_binary_layer",
srcs = ["//src/main/go/net/eagle0/admin_server:admin_server_linux_amd64"],
package_dir = "/app",
)
oci_image(
name = "admin_server_image",
base = "@alpine_linux_linux_amd64",
entrypoint = ["/app/admin_server_linux_amd64"],
exposed_ports = ["8080/tcp"],
tars = [
":busybox_layer",
":admin_binary_layer",
],
workdir = "/app",
)
# Load into Docker locally: bazel run //ci:admin_server_load
oci_load(
name = "admin_server_load",
image = ":admin_server_image",
repo_tags = ["eagle0/admin-server:latest"],
)
# Push to DigitalOcean Container Registry
oci_push(
name = "admin_server_push",
image = ":admin_server_image",
repository = "registry.digitalocean.com/eagle0/admin-server",
)
-29
View File
@@ -20,10 +20,6 @@ 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:
@@ -84,31 +80,6 @@ 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,6 +10,7 @@
#include "AiBattleConfig.hpp"
#include "AiBattleSimulator.hpp"
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
using shardok::ai_battle_simulator::AiBattleConfigLoader;
using shardok::ai_battle_simulator::AiBattleSimulator;
@@ -108,6 +109,10 @@ int main(int argc, char* argv[]) {
// Set exec path for FilesystemUtils
FilesystemUtils::SetExecPath(argv[0]);
// Set cache directory for ActionPointDistances
shardok::FixedActionPointDistances::SetCacheDirectory(
FilesystemUtils::CacheFilesDirectory() + "apdCache/");
try {
if (argc < 2) {
PrintUsage(argv[0]);
@@ -16,6 +16,7 @@
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/AIClientFactory.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/GamePhaseRunner.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
using namespace shardok;
@@ -83,6 +84,10 @@ int main(int argc, char* argv[]) {
// Set exec path so FilesystemUtils can find resource files
FilesystemUtils::SetExecPath(argv[0]);
// Set cache directory for ActionPointDistances
FixedActionPointDistances::SetCacheDirectory(
FilesystemUtils::CacheFilesDirectory() + "apdCache/");
try {
std::cout << "Shardok AI Performance Runner\n";
std::cout << "==============================\n";
@@ -29,6 +29,8 @@ 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;
@@ -38,13 +40,16 @@ 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\n",
"%d local misses, %d shared accesses, %d eviction events, "
"%d APD loaded from file, %d APD generated fresh\n",
cacheStats.persistentHits,
cacheStats.persistentMisses,
cacheStats.localHits,
cacheStats.localMisses,
cacheStats.sharedAccesses,
cacheStats.evictionEvents);
cacheStats.evictionEvents,
cacheStats.apdLoadedFromFile,
cacheStats.apdGeneratedFresh);
cacheStats.lastReportTime = now;
}
}
@@ -190,12 +195,25 @@ auto ActionPointDistancesCache::GetRaw(
}
// Create new pathfinding result using factory method
auto result = FixedActionPointDistances::Create(
auto creationResult = FixedActionPointDistances::Create(
mapToUse,
mapId.terrainTypesId,
mapId.modifierId,
battalionType,
includeBravingWater,
braveWaterActionPointCost);
#if CACHE_STATS_LOGGING_
// Track whether this was loaded from file or generated fresh
if (creationResult.loadedFromFile) {
cacheStats.apdLoadedFromFile++;
} else {
cacheStats.apdGeneratedFresh++;
}
#endif
auto result = creationResult.apd;
// Store in shared cache
sharedDistances.lazy_emplace_l(
cacheKey,
@@ -22,56 +22,110 @@ 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) -> std::shared_ptr<FixedActionPointDistances> {
int braveWaterActionPointCost) -> CreationResult {
// 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();
vector<std::future<vector<vector<DIST_T>>>> futures(indexCount);
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());
auto braveWaterPossibleCoords =
includeBravingWater ? apd->BraveWaterPossibleCoords(map) : nullptr;
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));
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;
vector<std::future<vector<vector<DIST_T>>>> futures(indexCount);
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;
});
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); }
}
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;
return result;
}
} // namespace shardok
@@ -17,19 +17,31 @@ 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:
// Factory method to create FixedActionPointDistances
static void SetCacheDirectory(const string &newDir);
// Factory method to create FixedActionPointDistances with metadata
static auto Create(
const HexMap *map,
int64_t terrainTypesHash,
int64_t modifierHash,
const BattalionTypeSPtr &battalionType,
bool includeBravingWater,
int braveWaterActionPointCost = -1) -> std::shared_ptr<FixedActionPointDistances>;
int braveWaterActionPointCost = -1) -> CreationResult;
~FixedActionPointDistances() override = default;
@@ -40,6 +52,8 @@ 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,6 +15,7 @@
#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"
@@ -44,6 +45,9 @@ 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,36 +311,20 @@ 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.
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) {
if (updateItem.ActionResultResponse.ActionResultViews.Any() ||
updateItem.ActionResultResponse.AvailableCommands == null ||
updateItem.ActionResultResponse.AvailableCommands.Token !=
_currentModel.CommandToken) {
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;
@@ -421,14 +405,9 @@ namespace eagle {
public void UpdateResultCounts(GameUpdate update) {
switch (update.GameUpdateDetailsCase) {
case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
var newCount = update.ActionResultResponse.UnfilteredResultCountAfter;
lock (_resultCountLock) {
var oldCount = _lastUnfilteredResultCount;
_lastUnfilteredResultCount = newCount;
if (newCount != oldCount) {
_connectionLogger.LogLine(
$"[RESULT_COUNT] Updated count {oldCount} -> {newCount}");
}
_lastUnfilteredResultCount =
update.ActionResultResponse.UnfilteredResultCountAfter;
}
break;
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: e9afdd2068b294a29993f07b379eb9c3
@@ -44,12 +44,6 @@ 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;
@@ -128,7 +122,6 @@ 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}");
@@ -201,14 +194,16 @@ namespace eagle {
var ackTcs = new TaskCompletionSource<SubscriptionAck>();
lock (_pendingSubscriptionAcks) { _pendingSubscriptionAcks[gameId] = ackTcs; }
var shardokCount = shardokStatuses.Count();
LogFlow($"SUBSCRIBE game={gameId} unfilteredCount={subscriber.LastUnfilteredResultCount} shardokGames={shardokCount}");
_remoteEagleClientLogger.LogLine(
$"[SUBSCRIBE] Sending StreamGameRequest for game {gameId}, " +
$"unfilteredCount={subscriber.LastUnfilteredResultCount}");
var sendSuccess = await DoWithStreamingCall(async (sc) => {
await sc.RequestStream.WriteAsync(request).ConfigureAwait(false);
LogFlow($"SUBSCRIBE sent for game={gameId}");
return true;
}).ConfigureAwait(false);
await sc.RequestStream.WriteAsync(request);
_remoteEagleClientLogger.LogLine(
$"[SUBSCRIBE] StreamGameRequest sent successfully for game {gameId}");
return true;
});
if (!sendSuccess) {
// Write failed - clean up and return
@@ -222,7 +217,7 @@ namespace eagle {
try {
var ackTask = ackTcs.Task;
var timeoutTask = Task.Delay(SubscriptionAckTimeoutMs, timeoutCts.Token);
var completedTask = await Task.WhenAny(ackTask, timeoutTask).ConfigureAwait(false);
var completedTask = await Task.WhenAny(ackTask, timeoutTask);
if (completedTask == timeoutTask) {
// Timeout waiting for ack
@@ -236,15 +231,24 @@ namespace eagle {
// Cancel the timeout task since ack was received
timeoutCts.Cancel();
var ack = await ackTask.ConfigureAwait(false);
var ack = await ackTask;
if (ack.Success) {
LogFlow($"SUBSCRIBE_ACK game={gameId} success=true confirmedCount={ack.ConfirmedResultCount}");
_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.
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) {
@@ -262,11 +266,9 @@ namespace eagle {
}
public async Task Connect() {
LogFlow($"Connect() called, state={_currentState}, failures={_consecutiveFailures}");
// Prevent concurrent connection attempts
if (_isConnecting) {
LogFlow("Connect() skipped - already connecting");
_remoteEagleClientLogger.LogLine($"Connect() skipped - already connecting");
return;
}
@@ -284,7 +286,6 @@ 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
@@ -317,19 +318,13 @@ 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) {
LogFlow($"Subscribing game {subscriber.GameId}...");
if (!await StreamOneGameAsync(subscriber)) {
LogFlow($"Subscription FAILED for game {subscriber.GameId}");
allSucceeded = false;
}
if (!await StreamOneGameAsync(subscriber)) { allSucceeded = false; }
}
if (!allSucceeded) {
@@ -358,7 +353,6 @@ 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");
@@ -476,8 +470,8 @@ namespace eagle {
break;
}
}
// Note: PostRequest is called inside each case, not here
// (removed duplicate PostRequest call that was causing double-posting)
await PostRequest(nextCommand);
}
}
@@ -509,9 +503,6 @@ namespace eagle {
}
public void Dispose() {
Thread threadToJoin = null;
CancellationTokenSource tokenSourceToDispose = null;
lock (this) {
// Dispose timers first to stop any pending callbacks
StopIdleCheckTimer();
@@ -534,25 +525,20 @@ namespace eagle {
// Clear pending commands
_pendingCommands.Clear();
// Cancel thread cancellation token (but don't dispose yet)
// Cancel and dispose thread cancellation token
_threadCancellationTokenSource?.Cancel();
// Capture thread and token source references for cleanup outside lock
threadToJoin = _streamingCallThread;
_streamingCallThread = null;
tokenSourceToDispose = _threadCancellationTokenSource;
// 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();
_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) {
@@ -576,18 +562,16 @@ 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
}
})
.ConfigureAwait(false);
await streamingCall.RequestStream.WriteAsync(new UpdateStreamRequest {
ErrorRequest =
new ErrorRequest {
ErrorMessage = errorMessage,
GameId = gameId,
StackTrace = stackTrace
}
});
return true;
}).ConfigureAwait(false);
});
}
public void Unsubscribe(IClientConnectionSubscriber subscriber) {
@@ -599,11 +583,10 @@ namespace eagle {
lock (this) { _pendingCommands.Add(request); }
await DoWithStreamingCall(async (streamingCall) => {
await streamingCall.RequestStream
.WriteAsync(new UpdateStreamRequest { PostCommandRequest = request })
.ConfigureAwait(false);
await streamingCall.RequestStream.WriteAsync(
new UpdateStreamRequest { PostCommandRequest = request });
return true;
}).ConfigureAwait(false);
});
lock (this) { _pendingCommands.Remove(request); }
} catch (Exception e) { Console.WriteLine("Failed to post command: " + e); }
}
@@ -684,20 +667,8 @@ namespace eagle {
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> call);
private async Task<bool> DoWithStreamingCall(WithStreamingCallDelegate action) {
try {
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) {
return await action(_streamingCall);
} catch (RpcException e) {
if (e.StatusCode == StatusCode.Cancelled) {
// This is expected when the connection is closed.
return false;
@@ -707,9 +678,6 @@ 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) {
@@ -718,18 +686,8 @@ namespace eagle {
}
try {
// 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);
await sc.RequestStream.WriteAsync(request, _cancellationToken);
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.
@@ -748,7 +706,7 @@ namespace eagle {
private void HandleGameUpdate(GameUpdate gameUpdate, DateTime receivedTime) {
switch (gameUpdate.GameUpdateDetailsCase) {
case GameUpdate.GameUpdateDetailsOneofCase.ErrorResponse:
LogFlow($"UPDATE ErrorResponse game={gameUpdate.GameId}");
_remoteEagleClientLogger.LogLine("Got an error response!");
MainQueue.Q.Enqueue(() => {
lock (this) { _subscribers.Remove(gameUpdate.GameId); }
});
@@ -756,6 +714,7 @@ 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(
@@ -764,22 +723,15 @@ namespace eagle {
str.StartingByteCount,
str.Completed);
}
// No MainQueue enqueue needed - ProcessPendingUpdates handles listener
// notification
break;
case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
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}");
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).
lock (this) {
if (_subscribers.TryGetValue(gameUpdate.GameId, out var sub)) {
sub.UpdateResultCounts(gameUpdate);
@@ -795,38 +747,19 @@ namespace eagle {
_subscribers.Remove(gameUpdate.GameId);
}
}
await TryPendingCommands();
});
break;
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);
var processTime = (DateTime.UtcNow - receivedTime).TotalMilliseconds;
if (processTime > 100.0) {
_timingsLogger.LogLine($"PROCESS {processTime} ms");
}
}
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;
@@ -942,10 +875,8 @@ namespace eagle {
waitStartTime = DateTime.UtcNow;
}
// 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}");
_remoteEagleClientLogger.LogLine(
"How did we get here? This is not my beautiful wife!");
} catch (RpcException e) {
lock (this) {
_lastDisconnect = DateTime.UtcNow;
@@ -1043,13 +974,7 @@ namespace eagle {
AutoReset = true,
Interval = 5000 // Check every 5 seconds
};
_idleCheckTimer.Elapsed += (sender, args) => {
try {
CheckForIdleTimeout();
} catch (Exception e) {
LogFlow($"IDLE_TIMER_ERROR {e.GetType().Name}: {e.Message}");
}
};
_idleCheckTimer.Elapsed += (sender, args) => CheckForIdleTimeout();
_idleCheckTimer.Enabled = true;
}
@@ -1062,16 +987,10 @@ namespace eagle {
}
private void CheckForIdleTimeout() {
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; }
var idleTime = (DateTime.UtcNow - _lastResponseReceived).TotalSeconds;
// Early warnings at 10s and 20s to help diagnose slow connections
if (idleTime > 20.0 && _lastIdleWarningLevel < 2) {
_lastIdleWarningLevel = 2;
@@ -1103,19 +1022,7 @@ namespace eagle {
StopHeartbeatTimer();
_heartbeatTimer =
new Timer { AutoReset = true, Interval = HeartbeatIntervalSeconds * 1000 };
_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.Elapsed += (sender, args) => Task.Run(() => SendHeartbeat());
_heartbeatTimer.Enabled = true;
}
@@ -1128,16 +1035,8 @@ namespace eagle {
}
private async Task SendHeartbeat() {
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;
}
if (_cancellationToken.IsCancellationRequested) { return; }
if (_currentState != ConnectionState.Connected) { return; }
// Build sync status for all subscribed games
var heartbeatRequest = new HeartbeatRequest {
@@ -1167,13 +1066,8 @@ namespace eagle {
var sent = await SendUpdateStreamRequestAsync(request);
if (sent) {
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");
_remoteEagleClientLogger.LogLine(
$"[HEARTBEAT] Sent heartbeat with {heartbeatRequest.GameSyncStatuses.Count} games");
}
}
@@ -1182,19 +1076,31 @@ namespace eagle {
private const double SyncMismatchGracePeriodSeconds = 60.0;
private void HandleHeartbeatResponse(HeartbeatResponse response) {
LogFlow($"HEARTBEAT_RESPONSE server_ts={response.ServerTimestamp}");
_remoteEagleClientLogger.LogLine(
$"[HEARTBEAT] Got response, server_timestamp={response.ServerTimestamp}");
// Check for sync mismatches reported by server
bool hasMismatch = false;
foreach (var syncResult in response.GameSyncResults) {
if (!syncResult.EagleInSync) {
LogFlow($"SYNC_MISMATCH game={syncResult.GameId} server_count={syncResult.ServerUnfilteredResultCount}");
_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}");
hasMismatch = true;
}
foreach (var shardokResult in syncResult.ShardokSyncResults) {
if (!shardokResult.InSync) {
LogFlow($"SYNC_MISMATCH game={syncResult.GameId} shardok={shardokResult.ShardokGameId} server_count={shardokResult.ServerFilteredResultCount}");
_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}");
hasMismatch = true;
}
}
@@ -1220,9 +1126,6 @@ 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,7 +28,11 @@ public class EagleConnection : IDisposable {
_channel = GrpcChannel.ForAddress(
"https://" + url,
new GrpcChannelOptions {
HttpHandler = CreateHttpHandler(),
HttpHandler =
new YetAnotherHttpHandler {
Http2Only = true,
Http2KeepAliveInterval = TimeSpan.FromSeconds(KeepAliveSeconds)
},
DisposeHttpClient = true,
LoggerFactory = _loggerFactory,
MaxReceiveMessageSize = null
@@ -38,25 +42,6 @@ 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,16 +6,12 @@ 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) {
lock (_loggersLock) {
if (!_loggers.ContainsKey(name)) { _loggers[name] = new Logger(name); }
return _loggers[name];
}
if (!_loggers.ContainsKey(name)) { _loggers[name] = new Logger(name); }
return _loggers[name];
}
private string CurrentTimeString() {
@@ -33,10 +29,8 @@ namespace common {
}
public void LogLine(string line) {
lock (_lock) {
_sw.WriteLine(CurrentTimeString() + " " + line);
_sw.Flush();
}
_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.11.4",
"com.cysharp.yetanotherhttphandler": "https://github.com/Cysharp/YetAnotherHttpHandler.git?path=src/YetAnotherHttpHandler#1.5.3",
"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.11.4",
"version": "https://github.com/Cysharp/YetAnotherHttpHandler.git?path=src/YetAnotherHttpHandler#1.5.3",
"depth": 0,
"source": "git",
"dependencies": {},
"hash": ""
"hash": "ceb2f24f343c93d66c8a784a283be65b9627efe1"
},
"com.unity.2d.sprite": {
"version": "1.0.0",
@@ -15,16 +15,5 @@ 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"],
)
+5 -11
View File
@@ -107,7 +107,7 @@ scala_library(
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
"//src/main/scala/net/eagle0/eagle/library/util: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:legacy_province_distances",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:province_gold_surplus_calculator",
],
)
@@ -182,7 +182,6 @@ 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",
@@ -215,7 +214,6 @@ 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",
],
)
@@ -257,21 +255,17 @@ scala_library(
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_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",
"//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",
"//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",
],
)
@@ -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.LegacyProvinceDistances
import net.eagle0.eagle.library.util.ProvinceDistances
object FactionLeaderProvinceRanker {
private def factionLeaderProvinceOrdering(
@@ -23,7 +23,7 @@ object FactionLeaderProvinceRanker {
gameState: GameState,
fromProvinceId: ProvinceId
): Ordering[Province] = Ordering.by((p: Province) =>
LegacyProvinceDistances.distanceThroughFriendliesOption(
ProvinceDistances.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, LegacyProvinceDistances}
import net.eagle0.eagle.library.util.{CommandSelection, LegacyBattalionUtils, ProvinceDistances}
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) =>
LegacyProvinceDistances
ProvinceDistances
.distanceThroughFriendliesOption(
destinationProvinceId,
p.id,
@@ -32,7 +32,6 @@ 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
@@ -627,7 +626,7 @@ object MidGameAIClient {
RandomState(
SeekMoreLeadersCommandChooser.maybeSeekMoreLeadersCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands
),
functionalRandom
@@ -905,7 +904,7 @@ object MidGameAIClient {
val furthestOrigin = candidateCommands.flatMap { opmc =>
// distance to the faction leader province we're closest to
factionLeaderProvincesNeedingHeroes.flatMap { flp =>
LegacyProvinceDistances
ProvinceDistances
.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 =>
LegacyProvinceDistances
ProvinceDistances
.closestProvinceThroughFriendliesOption(
desiredPid,
mcfop.availableDestinationProvinces.map(_.provinceId).toVector,
@@ -4,6 +4,9 @@ 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.{
@@ -12,30 +15,26 @@ import net.eagle0.eagle.library.util.command_choice_helpers.{
SwearBrotherhoodCommandSelector,
SwornBrotherChooser
}
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.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.FactionId
/** Protoless version of SeekMoreLeadersCommandChooser. */
object SeekMoreLeadersCommandChooser {
private def needsMoreLeaders(
actingFactionId: FactionId,
gameState: GameState
): Boolean =
gameState.factions(actingFactionId).leaderIds.size < SwornBrotherChooser
gameState.factions(actingFactionId).leaders.size < SwornBrotherChooser
.desiredNumberOfLeaders(
FactionUtils.provinces(actingFactionId, gameState.provinces.values.toVector).size
LegacyFactionUtils.provinceCount(actingFactionId, gameState)
)
case class Candidate(hero: HeroT, province: ProvinceT)
case class Candidate(hero: Hero, province: Province)
private def maybeMoveTowardDestination(
command: MarchAvailableCommand,
hero: HeroT,
startingProvince: ProvinceT,
factionHeadProvince: ProvinceT,
hero: Hero,
startingProvince: Province,
factionHeadProvince: Province,
gameState: GameState
): Option[CommandSelection] = {
val opmc = command.oneProvinceCommands
@@ -51,7 +50,7 @@ object SeekMoreLeadersCommandChooser {
factionHeadProvince.id,
p.id,
factionHeadProvince.rulingFactionId.get,
gameState.provinces
gameState
)
.map(distance => (opmc, p, distance))
}
@@ -84,24 +83,23 @@ object SeekMoreLeadersCommandChooser {
actingFactionId: FactionId,
gameState: GameState,
acs: Vector[AvailableCommand]
): Option[CommandSelection] = {
val provincesVector = gameState.provinces.values.toVector
FactionUtils
.provinces(actingFactionId, provincesVector)
): Option[CommandSelection] =
LegacyFactionUtils
.provinces(actingFactionId, gameState)
.find(
_.rulingFactionHeroIds
.contains(gameState.factions(actingFactionId).factionHeadId)
)
.flatMap { provinceWithFactionHead =>
val candidates =
FactionUtils
.provinces(actingFactionId, provincesVector)
LegacyFactionUtils
.provinces(actingFactionId, gameState)
.filter(_.rulingFactionHeroIds.length > 1)
.flatMap { province =>
SwornBrotherChooser
.bestChoice(
.bestChoiceProto(
province.rulingFactionHeroIds.map(gameState.heroes),
gameState.factions(actingFactionId).leaderIds
gameState.factions(actingFactionId).leaders.toVector
)
.map(hero => Candidate(hero, province))
}
@@ -119,7 +117,7 @@ object SeekMoreLeadersCommandChooser {
}
marchableCandidates
.maxByOption(x => HeroUtils.power(x._2.hero))
.maxByOption(x => LegacyHeroUtils.power(x._2.hero))
.flatMap {
case (command, candidate) =>
maybeMoveTowardDestination(
@@ -131,7 +129,6 @@ object SeekMoreLeadersCommandChooser {
)
}
}
}
def maybeSeekMoreLeadersCommand(
actingFactionId: FactionId,
@@ -145,9 +142,9 @@ object SeekMoreLeadersCommandChooser {
MoreOption
.flatWhen(needsMoreLeaders(actingFactionId, gameState)) {
// First try a hero that's already here
val desiredHero = SwornBrotherChooser.bestChoice(
val desiredHero = SwornBrotherChooser.bestChoiceProto(
leaderProvince.rulingFactionHeroIds.map(gameState.heroes),
faction.leaderIds
faction.leaders.toVector
)
desiredHero.flatMap { hero =>
@@ -163,6 +160,7 @@ object SeekMoreLeadersCommandChooser {
) {
CommandChoiceHelpers.chosenFeastCommandIfAvailable(
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = acs,
reason = s"want to make hero ${hero.id} a leader"
)
@@ -1,5 +1,18 @@
load("@rules_scala//scala:scala.bzl", "scala_library")
scala_library(
name = "action_pkg",
srcs = ["package.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/library:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
],
runtime_deps = [
"@maven//:com_thesamet_scalapb_lenses_3",
],
deps = ["//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto"],
)
scala_library(
name = "check_for_faction_changes_action",
srcs = ["CheckForFactionChangesAction.scala"],
@@ -89,7 +89,7 @@ case class PerformVassalCommandsPhaseAction(
commandOptions.collectFirst {
case ac: RestAvailableCommand =>
ac
}.isDefined && shouldRestProto(heroes)
}.isDefined && shouldRest(heroes)
) {
chosenRestCommand(actingFactionId, gsProto, commandOptions, reason)
}
@@ -0,0 +1,10 @@
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,24 +274,10 @@ 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",
],
)
@@ -1,59 +0,0 @@
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,7 +3,9 @@ package net.eagle0.eagle.library.util
import scala.annotation.tailrec
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
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.model.state.province.ProvinceT
object ProvinceDistances {
@@ -12,8 +14,8 @@ object ProvinceDistances {
p1: ProvinceId,
p2: ProvinceId,
fid: FactionId,
provinces: Map[ProvinceId, ProvinceT]
): Option[Int] = distance(p1, p2, FactionUtils.ownedNeighbors(provinces, fid))
gs: GameState
): Option[Int] = distance(p1, p2, LegacyFactionUtils.ownedNeighbors(gs, fid))
def distance(
p1: ProvinceId,
@@ -32,6 +34,18 @@ 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],
@@ -69,7 +83,7 @@ object ProvinceDistances {
def closestNeighborToFaction(
startingProvinceId: ProvinceId,
factionId: FactionId,
provinces: Map[ProvinceId, ProvinceT]
provinces: Map[ProvinceId, Province]
): Option[ProvinceId] = {
val factionProvinceIds = provinces.values
.filter(_.rulingFactionId.contains(factionId))
@@ -95,5 +109,34 @@ object ProvinceDistances {
end if
}
def closestNeighborToFaction(
startingProvinceId: ProvinceId,
factionId: FactionId,
provinces: Map[ProvinceId, ProvinceT]
)(using DummyImplicit): 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
}
case class ProvinceAndDistance(provinceId: ProvinceId, distance: Int)
}
@@ -209,12 +209,10 @@ 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",
@@ -469,10 +467,12 @@ scala_library(
"//src/test/scala/net/eagle0/eagle/library/util/command_choice_helpers:__pkg__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:hero_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:ai_minimum_charisma_for_sworn_brother",
"//src/main/scala/net/eagle0/eagle/library/settings:ai_minimum_constitution_for_sworn_brother",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
],
)
@@ -41,7 +41,7 @@ import net.eagle0.eagle.library.settings.{
VassalMinimumFoodToSendSupplies,
VassalMinimumGoldToSendSupplies
}
import net.eagle0.eagle.library.util.{CommandSelection, IncomingArmyUtils, LegacyProvinceDistances, ProvinceDistances}
import net.eagle0.eagle.library.util.{CommandSelection, IncomingArmyUtils, 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,13 +51,12 @@ 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.{HeroUtils, LegacyHeroUtils}
import net.eagle0.eagle.library.util.hero.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}
@@ -540,7 +539,7 @@ object CommandChoiceHelpers {
reason = reason
).filter {
case CommandSelection(_, _, ac, _, _) =>
shouldRestProto(
shouldRest(
gs.provinces(
ac.asInstanceOf[RestAvailableCommand].actingProvinceId
).rulingFactionHeroIds
@@ -548,16 +547,7 @@ object CommandChoiceHelpers {
)
}
/** 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 =
def shouldRest(heroes: Iterable[Hero]): Boolean =
if heroes
.map(LegacyHeroUtils.fatigue)
.min >= VassalCommandsMaxFatigueLevelBeforeResting.doubleValue
@@ -1040,7 +1030,7 @@ object CommandChoiceHelpers {
}
.flatMap {
case (leaderId, pid) =>
LegacyProvinceDistances
ProvinceDistances
.distanceThroughFriendliesOption(
p1 = actingProvinceId,
p2 = pid,
@@ -1145,6 +1135,7 @@ object CommandChoiceHelpers {
def chosenFeastCommandIfAvailable(
actingFactionId: FactionId,
gameState: GameState,
availableCommands: Vector[AvailableCommand],
reason: String
): Option[CommandSelection] =
@@ -1,12 +1,14 @@
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
import net.eagle0.eagle.library.util.hero.{HeroUtils, LegacyHeroUtils}
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]
@@ -17,6 +19,19 @@ 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, ProvinceId}
import net.eagle0.eagle.{FactionId, HeroId}
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,16 +124,6 @@ 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,10 +9,12 @@ 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",
@@ -31,11 +33,13 @@ 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,7 +150,6 @@ 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,7 +31,6 @@ 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,7 +6,6 @@ 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,7 +50,6 @@ 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,7 +9,6 @@ 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,7 +43,6 @@ 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,7 +9,6 @@ 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,7 +314,6 @@ 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",
],
)
@@ -396,7 +395,6 @@ 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,86 +359,79 @@ class EagleServiceImpl(
responseObserver: SyncResponseObserver
): Unit = {
lockAndDoWithUserName { userName =>
// 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()}"
)
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
)
}
GameSyncResult(
gameId = clientGameStatus.gameId,
eagleInSync = clientGameStatus.unfilteredResultCount == serverUnfilteredCount,
serverUnfilteredResultCount = serverUnfilteredCount,
shardokSyncResults = shardokSyncResults
// 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
)
}
}
}
// Only include sync results if there are mismatches (to reduce message size)
val mismatchedResults = gameSyncResults.filter { result =>
!result.eagleInSync || result.shardokSyncResults.exists(!_.inSync)
// 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 ""
}"
}
println(s"[HEARTBEAT] Detected sync mismatches for $userName: ${mismatchDetails.mkString("; ")}")
}
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
)
val _ = responseObserver.onNext(
UpdateStreamResponse(
responseDetails = UpdateStreamResponse.ResponseDetails.HeartbeatResponse(
HeartbeatResponse(
serverTimestamp = System.currentTimeMillis(),
gameSyncResults = mismatchedResults
)
)
)
}
)
}
()
}
@@ -8,10 +8,11 @@ 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)
)
@@ -19,7 +20,7 @@ object LocalGamePersisterCreation extends GamePersisterCreation {
S3Persister(S3Utils.mainBucketName, S3Utils.makeS3Prefix(gameId))
def persisterForGame(gameId: GameId): Persister =
if S3Credentials.isEnabled then
if includeS3 then
CompoundPersister(
Vector(localFilePersister(gameId), s3Persister(gameId))
)
@@ -12,7 +12,6 @@ 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
@@ -60,19 +59,16 @@ object ServerSetupHelpers {
.get
val localPersister = LocalFilePersister(SaveDirectory.saveDirectory)
val persisters = if S3Credentials.isEnabled then {
val s3Persister =
S3Persister(S3Utils.mainBucketName, S3Utils.runningGamesKeyPrefix)
Vector(localPersister, s3Persister)
} else {
Vector(localPersister)
}
val s3Persister =
S3Persister(S3Utils.mainBucketName, S3Utils.runningGamesKeyPrefix)
val _ = s3Persister
GamesManager(
shardokInternalInterface = shardokInternalInterface,
randomGenerator = new SecureRandom,
persister = CompoundPersister(persisters),
persister = CompoundPersister(
Vector(localPersister)
),
gameCreation = FixedNewGameCreation,
gamePersisterCreation = LocalGamePersisterCreation,
gptModelName = gptModelName,
@@ -85,10 +85,6 @@ 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"
@@ -200,10 +196,6 @@ 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,19 +24,18 @@ object S3Utils {
private def syncClient() = S3Client
.builder()
.endpointOverride(URI.create(S3Credentials.endpoint))
.endpointOverride(URI.create("https://sfo3.digitaloceanspaces.com"))
.region(Region.US_EAST_1)
.credentialsProvider(S3Credentials.credentialsProvider)
.build
private lazy val transferManager: S3TransferManager =
private val transferManager: S3TransferManager =
S3TransferManager.builder
.s3Client(
S3AsyncClient
.builder()
.endpointOverride(URI.create(S3Credentials.endpoint))
.endpointOverride(URI.create("https://sfo3.digitaloceanspaces.com"))
.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:__subpackages__",
"//src/main/scala/net/eagle0/eagle/service/persistence:__pkg__",
],
deps = [
"@maven//:software_amazon_awssdk_auth",
@@ -7,15 +7,6 @@ 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")
@@ -41,54 +32,12 @@ object S3Credentials {
}
}
/** 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 s3cfgMap = makeS3cfgMap().get
private val credentials = AwsBasicCredentials.create(
s3cfgMap("access_key"),
s3cfgMap("secret_key")
)
val credentialsProvider: AwsCredentialsProvider =
StaticCredentialsProvider.create(credentials)
}
}
@@ -52,8 +52,14 @@ TEST_F(FixedActionPointDistancesTest, testTiming) {
const auto start = system_clock::now();
for (const HexMapW& hexMap : hexMaps) {
auto result = shardok::FixedActionPointDistances::Create(hexMap, battalionType, true, 5);
ASSERT_NE(result, nullptr);
auto result = shardok::FixedActionPointDistances::Create(
hexMap,
0x1234,
0xABCD,
battalionType,
true,
5);
// The result contains both the APD and whether it was loaded from file
}
const auto end = system_clock::now();
+3 -10
View File
@@ -112,7 +112,8 @@ scala_test(
srcs = ["SeekMoreLeadersCommandChooserTest.scala"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_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/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",
@@ -120,14 +121,6 @@ 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/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",
"//src/main/scala/net/eagle0/eagle/library/util:idable",
],
)
@@ -1,6 +1,5 @@
package net.eagle0.eagle.ai
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
import net.eagle0.eagle.api.available_command.{
AvailableCommand,
AvailableDestinationProvince,
@@ -21,6 +20,12 @@ 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,
@@ -28,28 +33,14 @@ import net.eagle0.eagle.library.settings.{
MinimumLoyaltyForSwearBrotherhood
}
import net.eagle0.eagle.library.util.CommandSelection
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 net.eagle0.eagle.library.util.IDable.{mapifyFactions, mapifyHeroes, mapifyProvinces}
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))
@@ -72,104 +63,30 @@ class SeekMoreLeadersCommandChooserTest extends AnyFlatSpec with Matchers with B
private val tryToSwearBrotherhoodACs: Vector[AvailableCommand] =
Vector(heroGiftAC, feastAC, swearBrotherhoodAC)
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 = 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 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 =
@@ -209,7 +126,7 @@ class SeekMoreLeadersCommandChooserTest extends AnyFlatSpec with Matchers with B
inside(selection.selected) {
case SwearBrotherhoodSelectedCommand(
newBrotherHeroId,
_ /* unknownFieldSet */
_ /* uknownFieldSet */
) =>
newBrotherHeroId shouldBe 12
}
@@ -234,7 +151,7 @@ class SeekMoreLeadersCommandChooserTest extends AnyFlatSpec with Matchers with B
selection.available should not be swearBrotherhoodAC
selection.selected should not matchPattern {
case SwearBrotherhoodSelectedCommand(_, _ /* unknownFieldSet */ ) =>
case SwearBrotherhoodSelectedCommand(_, _ /* uknownFieldSet */ ) =>
}
}
@@ -247,11 +164,8 @@ class SeekMoreLeadersCommandChooserTest extends AnyFlatSpec with Matchers with B
)
)
val gsAlmostEnoughLoyalty = swearBrotherhoodGameState.copy(
heroes = swearBrotherhoodGameState.heroes.updated(
12,
swearBrotherhoodGameState.heroes(12).asInstanceOf[HeroC].copy(loyalty = 86)
)
val gsAlmostEnoughLoyalty = swearBrotherhoodGameState.update(
_.heroes(12).loyalty := 86
)
val selection = SeekMoreLeadersCommandChooser
@@ -264,7 +178,7 @@ class SeekMoreLeadersCommandChooserTest extends AnyFlatSpec with Matchers with B
selection.available shouldBe feastAC
selection.selected should matchPattern {
case FeastSelectedCommand(_ /* unknownFieldSet */ ) =>
case FeastSelectedCommand(_ /* uknownFieldSet */ ) =>
}
}
@@ -277,11 +191,8 @@ class SeekMoreLeadersCommandChooserTest extends AnyFlatSpec with Matchers with B
)
)
val gsFeastWontGiveEnough = swearBrotherhoodGameState.copy(
heroes = swearBrotherhoodGameState.heroes.updated(
12,
swearBrotherhoodGameState.heroes(12).asInstanceOf[HeroC].copy(loyalty = 84)
)
val gsFeastWontGiveEnough = swearBrotherhoodGameState.update(
_.heroes(12).loyalty := 84
)
val selection = SeekMoreLeadersCommandChooser
@@ -297,7 +208,7 @@ class SeekMoreLeadersCommandChooserTest extends AnyFlatSpec with Matchers with B
case HeroGiftSelectedCommand(
recipientHeroId,
amount,
_ /* unknownFieldSet */
_ /* uknownFieldSet */
) =>
recipientHeroId shouldBe 12
amount shouldBe 100
@@ -305,11 +216,10 @@ class SeekMoreLeadersCommandChooserTest extends AnyFlatSpec with Matchers with B
}
it should "do nothing if no available hero meets the minimums" in {
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))
val crappyHeroesGS = swearBrotherhoodGameState.update(
_.heroes(12).charisma := 64,
_.heroes(9).constitution := 63,
_.heroes(13).charisma := 60
)
SeekMoreLeadersCommandChooser
@@ -330,11 +240,8 @@ class SeekMoreLeadersCommandChooserTest extends AnyFlatSpec with Matchers with B
}
it should "do nothing if we don't need any more heroes" in {
val gsWithFewerProvinces = swearBrotherhoodGameState.copy(
provinces = swearBrotherhoodGameState.provinces.updated(
20,
swearBrotherhoodGameState.provinces(20).asInstanceOf[ProvinceC].copy(rulingFactionId = None)
)
val gsWithFewerProvinces = swearBrotherhoodGameState.update(
_.provinces(20).optionalRulingFactionId := None
)
SeekMoreLeadersCommandChooser
@@ -346,35 +253,38 @@ class SeekMoreLeadersCommandChooserTest extends AnyFlatSpec with Matchers with B
}
it should "move a candidate hero if that is an option" in {
val gameStateWithCandidateInNeighbor: GameState = makeGameState(
makeProvince(
id = 17,
rulingFactionId = Some(4),
rulingFactionHeroIds = Vector(1, 9, 13),
neighbors = Vector(Neighbor(provinceId = 20, startingPositionIndex = 0))
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))
),
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))
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))
)
)
val marchAC = MarchAvailableCommand(
@@ -768,7 +768,6 @@ scala_test(
"//src/main/scala/net/eagle0/eagle/model/proto_converters:action_result_proto_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero/concrete",
"//src/test/scala/net/eagle0/common:proto_matchers",
"//src/test/scala/net/eagle0/eagle/library/actions/impl:action_impl_pkg",
"//src/test/scala/net/eagle0/eagle/library/util/command_choice_helpers:selected_command_matcher",
@@ -49,7 +49,6 @@ 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
@@ -314,40 +313,28 @@ class PerformVassalCommandsPhaseActionTest extends AnyFlatSpec with Matchers wit
VassalMinimumGoldToSendSupplies.setIntValue(100)
}
"shouldRestProto" should "return false if no one is tired" in {
CommandChoiceHelpers.shouldRestProto(
"shouldRest" should "return false if no one is tired" in {
CommandChoiceHelpers.shouldRest(
Vector(fullyRestedHero1, fullyRestedHero2)
) shouldBe false
}
it should "return false if only one hero is tired" in {
CommandChoiceHelpers.shouldRestProto(
CommandChoiceHelpers.shouldRest(
Vector(fullyRestedHero1, veryTiredHero1)
) shouldBe false
}
it should "return false if all heroes are a little tired" in {
CommandChoiceHelpers.shouldRestProto(Vector(slightlyTiredHero)) shouldBe false
CommandChoiceHelpers.shouldRest(Vector(slightlyTiredHero)) shouldBe false
}
it should "return true if all heroes are very tired" in {
CommandChoiceHelpers.shouldRestProto(
CommandChoiceHelpers.shouldRest(
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(HeroC(id = 1, constitution = 100, vigor = 50), HeroC(id = 2, constitution = 100, vigor = 50))
) shouldBe true
}
"loyaltyManagementCommand" should "return a feast command if there are several heroes low on loyalty" in {
val developingProvince = actingProvince.withProvinceOrders(DEVELOP)
val developingGS =
@@ -102,26 +102,18 @@ 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",
],
)
@@ -1,266 +0,0 @@
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,13 +1,12 @@
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"
@@ -15,111 +14,253 @@ class ProvinceDistancesTest extends AnyFlatSpec with Matchers {
val factionId = 7
private def NeighborsFor(pids: Vector[ProvinceId]): Vector[Neighbor] =
pids.map(pid => Neighbor(provinceId = pid, startingPositionIndex = 0))
pids.map(pid => Neighbor(provinceId = pid))
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
)
private def mapifyProvinces(ps: ProvinceC*): Map[ProvinceId, ProvinceT] =
ps.map(p => p.id -> p).toMap
"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)))
"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))
)
ProvinceDistances.distanceThroughFriendliesOption(
val gs =
GameState(provinces = Vector(a, b, c, d, e, f).map(p => p.id -> p).toMap)
ProvinceDistances.closestProvinceThroughFriendliesOption(
1,
4,
Vector(4, 6),
factionId,
provinces
) should contain(2)
}
it should "report a longer distance around an unowned province" in {
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)))
)
ProvinceDistances.distanceThroughFriendliesOption(
1,
4,
factionId,
provinces
) should contain(3)
}
it should "return none if there is no route" in {
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)))
)
ProvinceDistances.distanceThroughFriendliesOption(
1,
4,
factionId,
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
gs
) 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)))
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))
)
// 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)
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
)
}
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)
ProvinceDistances.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)
ProvinceDistances.distanceThroughFriendliesOption(
1,
4,
factionId,
gs
) shouldBe empty
}
}
@@ -227,17 +227,13 @@ scala_test(
deps = [
":selected_command_matcher",
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//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/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/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",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
],
)
@@ -298,11 +294,11 @@ scala_test(
name = "sworn_brother_chooser_test",
srcs = ["SwornBrotherChooserTest.scala"],
deps = [
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//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/main/scala/net/eagle0/eagle/model/state/hero:profession",
"//src/main/scala/net/eagle0/eagle/model/state/hero/concrete",
"//src/test/scala/net/eagle0/common:proto_matchers",
],
)
@@ -1,16 +1,15 @@
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.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 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 org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.scalatest.BeforeAndAfterEach
@@ -19,67 +18,16 @@ 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: FactionId = 19
private val actingFactionId = 19
private val actingProvince: ProvinceC =
ProvinceC(
private val actingProvince =
Province(
id = 7,
rulingFactionId = Some(actingFactionId),
rulingHeroId = Some(1),
@@ -92,34 +40,64 @@ class ImproveCommandSelectorTest extends AnyFlatSpec with Matchers with BeforeAn
support = 50
)
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 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 gameState: GameState = makeGameState(actingProvince)(
fullyRestedHero1,
fullyRestedHero2,
slightlyTiredHero,
veryTiredHero1,
veryTiredHero2
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
)
it should "choose Economy if that is lower than agriculture and infrastructure" in {
val lowerEconomyProvince = actingProvince.copy(economy = 20, infrastructure = 30)
val lowerEconomyGameState = gameState.copy(
provinces = Map(actingProvince.id -> lowerEconomyProvince)
val lowerEconomyGameState = gameState.update(
_.provinces(actingProvince.id).economy := 20,
_.provinces(actingProvince.id).infrastructure := 30
)
val selection = ImproveCommandSelector
.chosenImproveCommand(
actingFactionId = actingFactionId,
gameState = lowerEconomyGameState,
gameState = GameStateConverter.fromProto(lowerEconomyGameState),
availableCommands = Vector(improveCommand)
)
.get
@@ -136,7 +114,7 @@ class ImproveCommandSelectorTest extends AnyFlatSpec with Matchers with BeforeAn
ImproveCommandSelector
.chosenImproveCommand(
actingFactionId = actingFactionId,
gameState = gameState,
gameState = GameStateConverter.fromProto(gameState),
availableCommands = Vector(RestAvailableCommand(actingProvinceId = actingProvince.id))
)
.shouldBe(empty)
@@ -145,7 +123,7 @@ class ImproveCommandSelectorTest extends AnyFlatSpec with Matchers with BeforeAn
val selection = ImproveCommandSelector
.chosenImproveCommand(
actingFactionId = actingFactionId,
gameState = gameState,
gameState = GameStateConverter.fromProto(gameState),
availableCommands = Vector(improveCommand)
)
.get
@@ -1,9 +1,9 @@
package net.eagle0.eagle.library.util.command_choice_helpers
import net.eagle0.common.ProtoMatchers.equalProto
import net.eagle0.eagle.common.profession.Profession
import net.eagle0.eagle.internal.hero.Hero
import net.eagle0.eagle.library.settings.{AiMinimumCharismaForSwornBrother, AiMinimumConstitutionForSwornBrother}
import net.eagle0.eagle.model.state.hero.concrete.HeroC
import net.eagle0.eagle.model.state.hero.Profession
import net.eagle0.eagle.HeroId
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.scalatest.BeforeAndAfterEach
@@ -16,62 +16,81 @@ class SwornBrotherChooserTest extends AnyFlatSpec with Matchers with BeforeAndAf
"bestChoice" should "choose the max power" in {
val heroes = Vector(
HeroC(id = 19: HeroId, constitution = 85, charisma = 71),
HeroC(id = 20: HeroId, constitution = 100, charisma = 70),
HeroC(id = 21: HeroId, constitution = 65, charisma = 65)
Hero(id = 19, constitution = 85, charisma = 71),
Hero(id = 20, constitution = 100, charisma = 70),
Hero(id = 21, constitution = 65, charisma = 65)
)
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)
SwornBrotherChooser.bestChoiceProto(heroes, Vector()).get should equalProto(
Hero(
id = 20,
constitution = 100,
charisma = 70
)
)
}
it should "choose someone with lower stats if they have a profession" in {
val heroes = Vector(
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)
Hero(id = 19, constitution = 85, charisma = 71),
Hero(id = 20, constitution = 100, charisma = 70),
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)
SwornBrotherChooser.bestChoiceProto(heroes, Vector()).get should equalProto(
Hero(
id = 21,
constitution = 65,
charisma = 65,
profession = Profession.MAGE
)
)
}
it should "not choose someone who is already a faction leader" in {
val heroes = Vector(
HeroC(id = 19: HeroId, constitution = 85, charisma = 71),
HeroC(id = 20: HeroId, constitution = 100, charisma = 70),
HeroC(id = 21: HeroId, constitution = 65, charisma = 65)
Hero(id = 19, constitution = 85, charisma = 71),
Hero(id = 20, constitution = 100, charisma = 70),
Hero(id = 21, constitution = 65, charisma = 65)
)
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)
SwornBrotherChooser.bestChoiceProto(heroes, Vector(20)).get should equalProto(
Hero(
id = 19,
constitution = 85,
charisma = 71
)
)
}
it should "not choose someone under the minimum even if they're otherwise better" in {
val heroes = Vector(
HeroC(id = 19: HeroId, constitution = 85, charisma = 71),
HeroC(id = 20: HeroId, constitution = 100, charisma = 64),
HeroC(id = 21: HeroId, constitution = 65, charisma = 65)
Hero(id = 19, constitution = 85, charisma = 71),
Hero(id = 20, constitution = 100, charisma = 64),
Hero(id = 21, constitution = 65, charisma = 65)
)
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)
SwornBrotherChooser.bestChoiceProto(heroes, Vector()).get should equalProto(
Hero(
id = 19,
constitution = 85,
charisma = 71
)
)
}
it should "return None if no one has the minimum stats" in {
val heroes = Vector(
HeroC(id = 19: HeroId, constitution = 85, charisma = 64),
HeroC(id = 20: HeroId, constitution = 100, charisma = 64),
HeroC(id = 21: HeroId, constitution = 64, charisma = 65)
Hero(id = 19, constitution = 85, charisma = 64),
Hero(id = 20, constitution = 100, charisma = 64),
Hero(id = 21, constitution = 64, charisma = 65)
)
SwornBrotherChooser.bestChoice(heroes, Vector()) shouldBe empty
SwornBrotherChooser.bestChoiceProto(heroes, Vector()) shouldBe empty
}
}