Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 4.5 8af547085d Fix sysroot for cross-compilation
Issues fixed:
1. Sysroot hosted on GitHub releases but repo is private, causing 404s
2. Sysroot missing GCC directories that clang needs to find libstdc++ headers

Changes:
- Add GCC installation directories to sysroot (clang uses these to locate C++ headers)
- Update workflow to upload sysroot to DO Spaces instead of GitHub releases
- Versioned sysroot paths (v2, v3, etc.) for easier updates

NOTE: After merging, run the "Build Linux Sysroot" workflow with version "v2",
then update MODULE.bazel with the sha256 from the workflow output.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 07:35:20 -08:00
237 changed files with 5551 additions and 12102 deletions
+2 -2
View File
@@ -26,8 +26,8 @@ common --host_cxxopt="--std=c++23"
common --javacopt="-Xlint:-options"
# suppress warnings due to https://developer.apple.com/forums/thread/733317
# Use host_linkopt for macOS-specific flags to avoid passing them to Linux cross-compilation
common:macos --host_linkopt=-Wl,-no_warn_duplicate_libraries
common --linkopt=-Wl
common:macos --linkopt=-Wl,-no_warn_duplicate_libraries
# Fix Xcode version caching issue - avoids need for `bazel clean --expunge` after Xcode updates
common:macos --repo_env=DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
+7 -10
View File
@@ -30,37 +30,34 @@ jobs:
- name: Install AWS CLI
run: |
if ! command -v aws &> /dev/null; then
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip -q awscliv2.zip
sudo ./aws/install
fi
sudo apt-get update
sudo apt-get install -y awscli
- name: Upload to DigitalOcean Spaces
env:
AWS_ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SECRET_KEY }}
run: |
# Upload sysroot tarball to DO Spaces (using eagle0-windows bucket, same as other workflows)
# Upload sysroot tarball to DO Spaces
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.tar.xz \
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz \
s3://eagle0/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
# Upload sha256 file
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256 \
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.sha256 \
s3://eagle0/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.sha256 \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
echo ""
echo "=== Sysroot uploaded ==="
echo "URL: https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz"
echo "URL: https://eagle0.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz"
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)"
echo ""
echo "Update MODULE.bazel with:"
echo "sysroot("
echo " name = \"linux_sysroot\","
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)\","
echo " urls = [\"https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
echo " urls = [\"https://eagle0.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
echo ")"
+5 -389
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/**'
@@ -26,8 +25,6 @@ permissions:
jobs:
build-eagle:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-eagle.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -35,15 +32,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')
@@ -53,168 +42,21 @@ jobs:
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
# Also set for current directory in case Bazel uses different home
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push Eagle image to DO registry
id: push-eagle
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
# Use cross-compiled image path from build step
EAGLE_IMAGE="${{ steps.build-eagle.outputs.image_path }}"
echo "Using Eagle image: $EAGLE_IMAGE"
if [ -z "$EAGLE_IMAGE" ] || [ ! -d "$EAGLE_IMAGE" ]; then
echo "ERROR: Eagle image not found at: $EAGLE_IMAGE"
exit 1
fi
# Debug: show OCI layout contents
echo "=== OCI Layout Contents ==="
cat "$EAGLE_IMAGE/index.json"
echo ""
echo "=== Blobs ==="
ls -la "$EAGLE_IMAGE/blobs/sha256/" | head -20
# Verify OCI layout consistency before pushing
echo "=== Verifying OCI layout consistency ==="
for digest in $(cat "$EAGLE_IMAGE/index.json" | grep -o '"sha256:[^"]*"' | tr -d '"'); do
blob_path="$EAGLE_IMAGE/blobs/${digest/://}"
if [ ! -f "$blob_path" ]; then
echo "ERROR: Blob not found: $blob_path"
exit 1
fi
actual_digest="sha256:$(shasum -a 256 "$blob_path" | cut -d' ' -f1)"
if [ "$digest" != "$actual_digest" ]; then
echo "ERROR: Digest mismatch for $blob_path"
echo " Index says: $digest"
echo " Actual: $actual_digest"
exit 1
fi
echo "✓ Verified: $digest"
done
# Build the push target to get crane in runfiles
bazel build //ci:eagle_server_push
# Use crane directly for push (avoids OCI->Docker digest mismatch)
CRANE="bazel-bin/ci/push_eagle_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
echo "Using crane: $CRANE"
# Push with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/eagle-server:${GIT_SHA}"
echo "Pushing eagle image: $IMAGE_TAG"
$CRANE push "$EAGLE_IMAGE" "$IMAGE_TAG"
# Verify push by checking what's in the registry
echo "=== Verifying push ==="
$CRANE manifest "$IMAGE_TAG" | head -50
PUSHED_DIGEST=$($CRANE digest "$IMAGE_TAG")
echo "Registry reports digest: $PUSHED_DIGEST"
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience (but deploy won't use it)
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest"
run: bazel run //ci:eagle_server_push
build-shardok:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Shardok binary (cross-compile for Linux)
run: |
set -ex
# Step 1: Build JUST the binary with cross-compilation
# We need --extra_toolchains to force the Linux toolchain to be used
# because toolchains_llvm registers with dev_dependency=True
echo "=== Building shardok-server binary for linux-x86_64 ==="
bazel build \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:all \
//src/main/cpp/net/eagle0/shardok:shardok-server
# Step 2: Check the binary directly from bazel-bin
# bazel-bin is a symlink that points to the correct output directory
LINUX_BIN="bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server"
echo "=== Checking binary at: $LINUX_BIN ==="
if [ ! -f "$LINUX_BIN" ]; then
echo "ERROR: Binary not found at $LINUX_BIN"
exit 1
fi
# Debug: show what bazel-bin points to
echo "bazel-bin symlink target: $(readlink bazel-bin || echo 'not a symlink')"
# Step 3: Verify it's ELF (Linux) not Mach-O (macOS)
echo "=== Verifying binary format ==="
MAGIC=$(head -c 4 "$LINUX_BIN" | xxd -p)
echo "Binary magic bytes: $MAGIC"
if [ "$MAGIC" = "7f454c46" ]; then
echo "SUCCESS: Binary is ELF format (Linux)"
elif [ "$MAGIC" = "cfaeedfe" ] || [ "$MAGIC" = "cffaedfe" ]; then
echo "ERROR: Binary is Mach-O format (macOS) - cross-compilation failed!"
echo ""
echo "Debug info:"
echo "- bazel-bin points to: $(readlink bazel-bin)"
file "$LINUX_BIN" || true
exit 1
else
echo "WARNING: Unknown binary format: $MAGIC"
file "$LINUX_BIN" || true
fi
- name: Build Shardok Docker image
id: build-shardok
run: |
set -ex
# Build the OCI image with cross-compilation flags
bazel build \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:all \
//ci:shardok_server_image
# The image is output to bazel-bin which is a symlink.
# Resolve it now before any other bazel commands change where it points.
IMAGE_PATH=$(readlink -f bazel-bin/ci/shardok_server_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
# Verify the binary inside the tar layer is ELF
echo "=== Verifying binary in image tar ==="
BINARY_TAR="bazel-bin/ci/shardok_binary_layer.tar"
if [ -f "$BINARY_TAR" ]; then
echo "Checking binary in $BINARY_TAR"
# Extract just the first 4 bytes of the binary from the tar
MAGIC=$(tar -xOf "$BINARY_TAR" app/shardok-server 2>/dev/null | head -c 4 | xxd -p)
echo "Binary magic in tar: $MAGIC"
if [ "$MAGIC" = "7f454c46" ]; then
echo "SUCCESS: Binary in tar is ELF format (Linux)"
else
echo "ERROR: Binary in tar is NOT ELF format!"
echo "This means pkg_tar is packaging the wrong binary."
exit 1
fi
else
echo "WARNING: Could not find $BINARY_TAR"
fi
- name: Build Shardok Docker image (cross-compile for Linux)
run: bazel build --platforms=//:linux_x86_64 //ci:shardok_server_image
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
@@ -224,233 +66,7 @@ jobs:
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
# Also set for current directory in case Bazel uses different home
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push Shardok image to DO registry
id: push-shardok
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
# Use cross-compiled image path from build step
CROSS_IMAGE="${{ steps.build-shardok.outputs.image_path }}"
echo "Using cross-compiled image: $CROSS_IMAGE"
if [ -z "$CROSS_IMAGE" ] || [ ! -d "$CROSS_IMAGE" ]; then
echo "ERROR: Cross-compiled image not found at: $CROSS_IMAGE"
exit 1
fi
# Get crane from Eagle push target (which doesn't need cross-compilation)
# This gives us a macOS crane binary we can actually run.
# We can't build shardok_server_push with platform flags because it would
# download a Linux crane that can't run on macOS.
bazel build //ci:eagle_server_push
# Find the Darwin crane binary (may be a symlink)
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
if [ -z "$CRANE" ]; then
# Fallback to any crane
CRANE=$(find "$RUNFILES" -name crane 2>/dev/null | head -1)
fi
if [ -z "$CRANE" ] || [ ! -e "$CRANE" ]; then
echo "ERROR: crane not found. Listing runfiles:"
find "$RUNFILES" -name crane 2>/dev/null || true
exit 1
fi
echo "Using crane: $CRANE"
# Push the cross-compiled image with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/shardok-server:${GIT_SHA}"
echo "Pushing shardok image: $IMAGE_TAG"
$CRANE push "$CROSS_IMAGE" "$IMAGE_TAG"
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience (but deploy won't use it)
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/shardok-server:latest"
build-admin:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-admin.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Admin Server Docker image
id: build-admin
run: |
set -ex
# Build admin server image (Go binary has explicit goos/goarch in BUILD.bazel)
bazel build //ci:admin_server_image
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/admin_server_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push Admin image to DO registry
id: push-admin
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
ADMIN_IMAGE="${{ steps.build-admin.outputs.image_path }}"
echo "Using Admin image: $ADMIN_IMAGE"
if [ -z "$ADMIN_IMAGE" ] || [ ! -d "$ADMIN_IMAGE" ]; then
echo "ERROR: Admin image not found at: $ADMIN_IMAGE"
exit 1
fi
# Build the push target to get crane in runfiles
bazel build //ci:admin_server_push
# Use crane directly for push
CRANE="bazel-bin/ci/push_admin_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
echo "Using crane: $CRANE"
# Push with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/admin-server:${GIT_SHA}"
echo "Pushing admin image: $IMAGE_TAG"
$CRANE push "$ADMIN_IMAGE" "$IMAGE_TAG"
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/admin-server:latest"
deploy:
runs-on: ubuntu-latest
needs: [build-eagle, build-shardok, build-admin]
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
environment: production
env:
EAGLE_IMAGE: ${{ needs.build-eagle.outputs.image_tag }}
SHARDOK_IMAGE: ${{ needs.build-shardok.outputs.image_tag }}
ADMIN_IMAGE: ${{ needs.build-admin.outputs.image_tag }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GPT_MODEL_NAME: ${{ secrets.GPT_MODEL_NAME }}
EAGLE_ENABLE_S3: ${{ secrets.EAGLE_ENABLE_S3 }}
DO_SPACES_ACCESS_KEY: ${{ secrets.DO_SPACES_ACCESS_KEY }}
DO_SPACES_SECRET_KEY: ${{ secrets.DO_SPACES_SECRET_KEY }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Copy config files to droplet
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
source: "docker-compose.prod.yml,nginx/nginx.conf"
target: "/opt/eagle0"
- name: Deploy to production droplet
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
script_stop: true
envs: EAGLE_IMAGE,SHARDOK_IMAGE,ADMIN_IMAGE,OPENAI_API_KEY,GPT_MODEL_NAME,EAGLE_ENABLE_S3,DO_SPACES_ACCESS_KEY,DO_SPACES_SECRET_KEY
script: |
set -x
cd /opt/eagle0
# Write env vars to .env file for docker-compose
rm -f .env 2>/dev/null || true
cat > .env << EOF
OPENAI_API_KEY=${OPENAI_API_KEY:-}
GPT_MODEL_NAME=${GPT_MODEL_NAME:-gpt-4o}
EAGLE_ENABLE_S3=${EAGLE_ENABLE_S3:-false}
DO_SPACES_ACCESS_KEY=${DO_SPACES_ACCESS_KEY:-}
DO_SPACES_SECRET_KEY=${DO_SPACES_SECRET_KEY:-}
EOF
chmod 600 .env
# Login to registry
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
# Use exact image tags passed from build jobs (no :latest fallback)
echo "Using images: $EAGLE_IMAGE, $SHARDOK_IMAGE, $ADMIN_IMAGE"
# Use crane to pull images (handles OCI format correctly) then load into Docker
# This avoids digest mismatch from DO registry's OCI->Docker format conversion
echo "Installing crane..."
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
chmod +x crane
# crane uses Docker config for auth
echo "Pulling Eagle image with crane..."
./crane pull "${EAGLE_IMAGE}" eagle.tar || { echo "ERROR: Failed to pull eagle image"; exit 1; }
echo "Loading Eagle image into Docker..."
docker load -i eagle.tar
rm eagle.tar
echo "Pulling Shardok image with crane..."
./crane pull "${SHARDOK_IMAGE}" shardok.tar || { echo "ERROR: Failed to pull shardok image"; exit 1; }
echo "Loading Shardok image into Docker..."
docker load -i shardok.tar
rm shardok.tar
echo "Pulling Admin image with crane..."
./crane pull "${ADMIN_IMAGE}" admin.tar || { echo "ERROR: Failed to pull admin image"; exit 1; }
echo "Loading Admin image into Docker..."
docker load -i admin.tar
rm admin.tar
rm ./crane
# Also pull other compose images
docker pull nginx:alpine || true
docker pull certbot/certbot || true
echo "All images pulled successfully"
# Force recreate containers to ensure new image is used
docker compose -f docker-compose.prod.yml up -d --force-recreate --remove-orphans
# Restart nginx to pick up new container IPs
# (nginx caches DNS at startup, so it needs restart after eagle/shardok)
docker compose -f docker-compose.prod.yml restart nginx
# Wait for health checks
sleep 10
docker compose -f docker-compose.prod.yml ps
# Verify containers are using correct images
echo "=== Verifying container image tags ==="
docker compose -f docker-compose.prod.yml images
# Cleanup old images
docker image prune -f
run: bazel run --platforms=//:linux_x86_64 //ci:shardok_server_push
-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
+5 -26
View File
@@ -72,8 +72,9 @@ use_repo(llvm, "llvm_toolchain", "llvm_toolchain_linux")
sysroot = use_repo_rule("@toolchains_llvm//toolchain:sysroot.bzl", "sysroot")
sysroot(
name = "linux_sysroot",
sha256 = "a06475004fe8003ae7ccb4fe1d5511feb9b27cce4a8826eb1dfd686ed83f3dba",
urls = ["https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/v3/ubuntu_noble_amd64_sysroot.tar.xz"],
# TODO: Update sha256 after running sysroot build workflow with version v2
sha256 = "PLACEHOLDER_RUN_SYSROOT_WORKFLOW_FIRST",
urls = ["https://eagle0.sfo3.digitaloceanspaces.com/sysroot/v2/ubuntu_noble_amd64_sysroot.tar.xz"],
)
#
@@ -145,15 +146,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
@@ -213,12 +206,9 @@ maven.install(
"org.reactivestreams:reactive-streams:1.0.4",
"javax.xml.bind:jaxb-api:2.3.1",
# OkHttp (for SSE with read timeout support, OAuth HTTP calls)
# OkHttp (for SSE with read timeout support)
"com.squareup.okhttp3:okhttp:4.12.0",
"com.squareup.okhttp3:okhttp-sse:4.12.0",
# JWT (for OAuth token handling)
"com.nimbusds:nimbus-jose-jwt:9.37.3",
],
duplicate_version_warning = "error",
fail_if_repin_required = True,
@@ -234,7 +224,6 @@ use_repo(maven, "maven", "unpinned_maven")
#
http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file")
# GTL (for parallel_hashmap)
GTL_VERSION = "1.2.0"
@@ -263,16 +252,6 @@ http_archive(
],
)
# Busybox static binary for Docker health checks (provides nc, wget, etc.)
# https://busybox.net/downloads/binaries/
http_file(
name = "busybox_x86_64",
sha256 = "6e123e7f3202a8c1e9b1f94d8941580a25135382b99e8d3e34fb858bba311348",
urls = ["https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox"],
downloaded_file_path = "busybox",
executable = True,
)
#
# Toolchain Registration
#
+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",
-65
View File
@@ -1,22 +1,6 @@
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load", "oci_push")
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
#
# Shared utilities layer (busybox for nc, wget, etc.)
#
pkg_tar(
name = "busybox_layer",
srcs = ["@busybox_x86_64//file"],
package_dir = "/usr/local/bin",
remap_paths = {
"file/busybox": "busybox",
},
symlinks = {
"/usr/local/bin/nc": "busybox",
},
)
#
# Eagle Server Docker Image
#
@@ -61,7 +45,6 @@ oci_image(
},
exposed_ports = ["40032/tcp"],
tars = [
":busybox_layer",
":eagle_server_jar_layer",
":eagle_resources_layer",
],
@@ -76,9 +59,6 @@ oci_load(
)
# Push to DigitalOcean Container Registry
# Note: No remote_tags here - DigitalOcean converts OCI to Docker format,
# changing the digest and breaking oci_push's tag-by-digest logic.
# Tagging is handled in the CI workflow using crane copy/tag.
oci_push(
name = "eagle_server_push",
image = ":eagle_server_image",
@@ -126,7 +106,6 @@ oci_image(
"40052/tcp",
],
tars = [
":busybox_layer",
":shardok_binary_layer",
":shardok_resources_layer",
":shardok_maps_layer",
@@ -142,52 +121,8 @@ oci_load(
)
# Push to DigitalOcean Container Registry
# Note: No remote_tags here - DigitalOcean converts OCI to Docker format,
# changing the digest and breaking oci_push's tag-by-digest logic.
# Tagging is handled in the CI workflow using crane copy/tag.
oci_push(
name = "shardok_server_push",
image = ":shardok_server_image",
repository = "registry.digitalocean.com/eagle0/shardok-server",
)
#
# Admin Server Docker Image (Go)
#
# Build: bazel build //ci:admin_server_image
# Load: bazel run //ci:admin_server_load
# Push: bazel run //ci:admin_server_push
#
# Package the Go admin binary (explicit Linux x86_64 target)
pkg_tar(
name = "admin_binary_layer",
srcs = ["//src/main/go/net/eagle0/admin_server:admin_server_linux_amd64"],
package_dir = "/app",
)
oci_image(
name = "admin_server_image",
base = "@alpine_linux_linux_amd64",
entrypoint = ["/app/admin_server_linux_amd64"],
exposed_ports = ["8080/tcp"],
tars = [
":busybox_layer",
":admin_binary_layer",
],
workdir = "/app",
)
# Load into Docker locally: bazel run //ci:admin_server_load
oci_load(
name = "admin_server_load",
image = ":admin_server_image",
repo_tags = ["eagle0/admin-server:latest"],
)
# Push to DigitalOcean Container Registry
oci_push(
name = "admin_server_push",
image = ":admin_server_image",
repository = "registry.digitalocean.com/eagle0/admin-server",
)
+15 -86
View File
@@ -1,118 +1,47 @@
# Docker Compose for production deployment
#
# Local testing:
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:shardok_server_load
# Run: docker compose -f docker-compose.prod.yml up
#
# Production deployment:
# Run: docker compose -f docker-compose.prod.yml up -d
# Docker Compose for local testing of production images
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:shardok_server_load
# Run: docker compose -f docker-compose.prod.yml up
services:
eagle:
image: ${EAGLE_IMAGE:-registry.digitalocean.com/eagle0/eagle-server:latest}
image: eagle0/eagle-server:latest
container_name: eagle-server
command:
- "--gpt-model-name"
- "${GPT_MODEL_NAME:-gpt-5.1}"
- "--shardok-interface-remote-address"
- "shardok:40042"
ports:
- "40032:40032"
environment:
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
# Eagle server configuration
EAGLE_GRPC_PORT: "40032"
SHARDOK_HOST: "shardok"
SHARDOK_PORT: "40042"
# Resource paths (relative to /app in container)
EAGLE_RESOURCES_PATH: "/app/resources"
volumes:
# Mount saves directory for persistence
- ./saves:/app/saves
depends_on:
- shardok
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 40032 || exit 1"]
test: ["CMD", "nc", "-z", "localhost", "40032"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
shardok:
image: ${SHARDOK_IMAGE:-registry.digitalocean.com/eagle0/shardok-server:latest}
image: eagle0/shardok-server:latest
container_name: shardok-server
ports:
- "40042:40042"
- "40052:40052"
environment:
# Shardok server configuration
SHARDOK_RESOURCES_PATH: "/app/resources"
SHARDOK_MAPS_PATH: "/app/resources/maps"
SHARDOK_EAGLE_INTERFACE_ADDRESS: "0.0.0.0:40042"
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 40042 || exit 1"]
test: ["CMD", "nc", "-z", "localhost", "40042"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
nginx:
image: nginx:alpine
container_name: nginx
ports:
- "443:443"
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./certbot/conf:/etc/letsencrypt:ro
- ./certbot/www:/var/www/certbot:ro
- ./auth:/etc/nginx/auth:ro
depends_on:
- eagle
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
admin:
image: ${ADMIN_IMAGE:-registry.digitalocean.com/eagle0/admin-server:latest}
container_name: admin-server
command:
- "--eagle-addr"
- "eagle:40032"
- "--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
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
+7 -39
View File
@@ -197,9 +197,9 @@ Change APIs to vend Scala `GameState` and `ActionResultT` instead of proto versi
### Remaining Proto Usage in Actions
**Progress: 47 of 52 action files (90%) are fully protoless.**
**Progress: 46 of 52 action files (88%) are fully protoless.**
The following 5 actions still have proto usage:
The following 6 actions still have proto usage:
| Action | Proto Usages | Blocker | Effort |
|--------|--------------|---------|--------|
@@ -208,8 +208,7 @@ The following 5 actions still have proto usage:
| `EndHandleRiotsPhaseAction` | 2 | `CommandChoiceHelpers` takes proto GameState | Medium |
| `PerformVassalDefenseDecisionsAction` | 2 | `CommandChoiceHelpers` takes proto GameState | Medium |
| `EndVassalCommandsPhaseAction` | 1 | `CommandChoiceHelpers` takes proto GameState | Medium |
**Note:** `NewRoundAction` is now fully protoless after converting `ChronicleEventGenerator` to return Scala `ChronicleEvent` types directly.
| `NewRoundAction` | 1 | `ChronicleEventGenerator` returns proto | Medium |
**Deleted Dead Code:**
- `UnaffiliatedHeroMovedAction` - Was never called; `PerformUnaffiliatedHeroesAction.heroMovedResult` constructs `ActionResultC` directly
@@ -225,47 +224,16 @@ The following 5 actions still have proto usage:
|-----------|-------|------------|--------|
| `CommandChoiceHelpers` to Scala | ~2000 | High | 4 vassal actions |
| `ResolveBattleAction` refactor | ~500 | High | 1 action (complex) |
| `ChronicleEventGenerator` to Scala | ~400 | Medium | 1 action |
| History API updates | ~100 | Low | - |
| **Total Remaining** | **~2600** | | |
**Completed:**
- `ChronicleEventGenerator` converted to return Scala `ChronicleEvent` types directly
### CommandChoiceHelpers Migration Status
Several command selectors have already been converted to use Scala types:
| File | Status | Notes |
|------|--------|-------|
| `AttackCommandChooser.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
| `AlmsCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
| `FoodConsumptionUtils.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `RoundPhase` |
| `MarchSuppliesHelpers.scala` | ✅ **Protoless** | Uses `BattalionT` |
| `CombatUnitSelector.scala` | ✅ **Protoless** | Uses `HeroT`, `BattalionT`, `BattalionType` |
| `ExpandCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `FactionT` |
| `ImproveCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `HeroT` |
| `OrganizeCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `BattalionT`, `BattalionType` |
| `RansomOfferHelpers.scala` | ✅ **Protoless** | Uses Scala `GameState`, `FactionT` |
| `CommandChoiceHelpers.scala` | ❌ Proto | Main entry point, converts to Scala when calling converted selectors |
| `ProvinceGoldSurplusCalculator.scala` | **Partial** | Has both Scala and proto overloads |
| Other selectors | ❌ Proto | Various proto dependencies |
**Pattern**: `CommandChoiceHelpers` currently uses `GameStateConverter.fromProto(gameState)` when calling already-converted selectors like `AlmsCommandSelector` and `AttackCommandChooser`. This allows incremental migration.
**Next Steps**:
1. ~~Convert `ExpandCommandSelector` to Scala types~~ ✅ Done
2. ~~Convert `ImproveCommandSelector` to Scala types~~ ✅ Done
3. ~~Convert `OrganizeCommandSelector` to Scala types~~ ✅ Done (PR #4812)
4. ~~Convert `RansomOfferHelpers` to Scala types~~ ✅ Done (PR #4821)
5. Convert remaining selectors one at a time
6. Update `CommandChoiceHelpers` to accept Scala `GameState` once all selectors are converted
| **Total Remaining** | **~3000** | | |
### Progress Summary
| Metric | Value |
|--------|-------|
| Action files fully protoless | 47 / 52 (90%) |
| Proto usages in remaining actions | 32 total |
| Action files fully protoless | 46 / 52 (88%) |
| Proto usages in remaining actions | 33 total |
| Biggest blocker | `ResolveBattleAction` (24 usages) |
| Second biggest blocker | `CommandChoiceHelpers` (blocks 4 actions) |
-189
View File
@@ -1,189 +0,0 @@
# Discord + Google OAuth Implementation Plan
## Overview
Replace HTTP Basic Auth with OAuth 2.0 (Discord + Google) for Eagle0. Users authenticate via system browser, receive JWT tokens, and choose their own display names.
## Architecture
```
Unity Client Eagle Server
| |
| 1. Click "Login with Discord/Google" |
| -------------------------------------------------> |
| GetOAuthUrl(provider) -> auth_url + state |
| |
| 2. Open system browser -> OAuth consent |
| 3. User authenticates with provider |
| 4. Redirect to eagle0://auth/callback?code=xxx |
| |
| 5. ExchangeCode(code, state) |
| -------------------------------------------------> |
| Exchange code with provider |
| Fetch user info (id, email, avatar) |
| Create/update user record |
| Issue JWT + refresh token |
| <------------------------------------------------- |
| (jwt, refresh_token, user_info, is_new_user) |
| |
| 6. [If new user] SetDisplayName(name) |
| -------------------------------------------------> |
| |
| 7. Subsequent gRPC calls |
| Authorization: Bearer <jwt> |
| -------------------------------------------------> |
```
## Key Design Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| OAuth flow | System browser + deep link | Secure, supports password managers |
| Code exchange | Eagle server directly | No separate auth service needed |
| JWT signing | RS256 (asymmetric) | Future flexibility for token verification |
| User storage | Protobuf file via Persister | Consistent with existing patterns |
| Token expiry | 7-day access, 30-day refresh | Balance security and gaming UX |
## Implementation Phases
### Phase 1: Proto Definitions & Infrastructure
**New files:**
- `src/main/protobuf/net/eagle0/eagle/api/auth.proto` - Auth API messages
- `src/main/protobuf/net/eagle0/eagle/internal/user.proto` - User storage schema
**Key proto messages:**
```protobuf
// API
GetOAuthUrlRequest/Response // Get OAuth URL to open in browser
ExchangeCodeRequest/Response // Exchange auth code for JWT
SetDisplayNameRequest/Response // Set user's display name
RefreshTokenRequest/Response // Refresh expired access token
// Internal storage
User // user_id, display_name, oauth_identities
UserDatabase // All users + indexes for lookup
```
### Phase 2: Eagle Server Auth Services
**New Scala files:**
- `src/main/scala/net/eagle0/eagle/auth/OAuthConfig.scala` - Discord/Google config from env vars
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - JWT creation/validation (RS256)
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD, display name validation
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth code exchange
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC service implementation
**Modify:**
- `src/main/scala/net/eagle0/eagle/service/AuthorizationInterceptor.scala`
- Replace Basic Auth parsing with JWT validation
- Skip auth for public endpoints (GetOAuthUrl, ExchangeCode, RefreshToken)
- `src/main/scala/net/eagle0/eagle/service/AuthorizationUtils.scala`
- Change context keys from `userName` to `userId` + `displayName`
- `src/main/scala/net/eagle0/eagle/service/Main.scala`
- Wire up new auth services and JWT key loading
### Phase 3: Unity Client OAuth Flow
**New C# files:**
- `Assets/Auth/OAuthManager.cs` - OAuth flow + deep link handling
- `Assets/Auth/TokenStorage.cs` - Secure token persistence
- `Assets/Auth/AuthClient.cs` - gRPC client for auth service
**Modify:**
- `Assets/EagleConnection.cs`
- Replace `AuthInterceptor` (Basic Auth) with `JwtAuthInterceptor` (Bearer token)
- `Assets/ConnectionHandler/ConnectionHandler.cs`
- Replace username/password UI with Discord/Google login buttons
- Add display name setup flow for new users
### Phase 4: Platform Configuration
**Deep link registration:**
- iOS: Add `eagle0://` to CFBundleURLSchemes in Info.plist
- Android: Add intent-filter for `eagle0://auth` in AndroidManifest.xml
- Desktop: Register URL scheme (Windows registry / macOS plist)
**OAuth provider setup:**
1. Discord Developer Portal: Create app, add redirect URI `eagle0://auth/callback`
2. Google Cloud Console: Create OAuth client, add redirect URI
**Environment variables (server):**
```
DISCORD_CLIENT_ID
DISCORD_CLIENT_SECRET
GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET
JWT_PRIVATE_KEY_PATH
JWT_PUBLIC_KEY_PATH
```
### Phase 5: Testing
**Unit tests:**
- `JwtServiceSpec.scala` - Token creation/validation
- `UserServiceSpec.scala` - Display name validation, uniqueness
- `OAuthServiceSpec.scala` - OAuth flow with mocked providers
**Integration tests:**
- Full OAuth flow with mock provider
- JWT validation in AuthorizationInterceptor
- gRPC calls with valid/invalid tokens
**Manual testing:**
- [ ] Discord login (Windows, macOS)
- [ ] Google login (Windows, macOS)
- [ ] Deep link callback works
- [ ] Display name validation
- [ ] Session persistence across restarts
- [ ] Token refresh
## Files Summary
### Create
| File | Purpose |
|------|---------|
| `src/main/protobuf/net/eagle0/eagle/api/auth.proto` | Auth API definitions |
| `src/main/protobuf/net/eagle0/eagle/internal/user.proto` | User storage schema |
| `src/main/scala/net/eagle0/eagle/auth/OAuthConfig.scala` | Provider config |
| `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` | JWT handling |
| `src/main/scala/net/eagle0/eagle/auth/UserService.scala` | User management |
| `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` | OAuth flow |
| `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` | gRPC service |
| `Assets/Auth/OAuthManager.cs` | Unity OAuth manager |
| `Assets/Auth/TokenStorage.cs` | Token storage |
| `Assets/Auth/AuthClient.cs` | Auth gRPC client |
### Modify
| File | Changes |
|------|---------|
| `AuthorizationInterceptor.scala` | Basic Auth -> JWT validation |
| `AuthorizationUtils.scala` | userName -> userId + displayName |
| `Main.scala` | Wire auth services |
| `EagleConnection.cs` | AuthInterceptor -> JwtAuthInterceptor |
| `ConnectionHandler.cs` | Login UI -> OAuth buttons + display name |
### Delete
- nginx htpasswd configuration (no longer needed)
## Security Considerations
1. **State parameter** - CSRF protection in OAuth flow
2. **PKCE** - Consider adding for mobile (enhancement)
3. **Secure storage** - Use Keychain (iOS) / Keystore (Android) for tokens
4. **Token refresh** - 7-day access tokens with 30-day refresh
5. **Rate limiting** - Limit login attempts per IP
## Dependencies to Add
**Scala (MODULE.bazel):**
- JWT library (e.g., `jwt-scala` or `nimbus-jose-jwt`)
- HTTP client (e.g., `sttp` for OAuth requests)
**Unity:**
- Deep linking is built-in (Unity 2021+)
- No additional packages required
## Rollback Plan
Keep Basic Auth code in a feature branch. Both auth methods can coexist during transition via feature flag if needed.
-62
View File
@@ -348,68 +348,6 @@ services:
---
## Docker Networking & Resource Configuration
### Container Network Binding
In Docker, each container has its own network namespace. When a server binds to `localhost` (127.0.0.1), it only accepts connections from within the same container. Other containers on the Docker bridge network cannot connect, even if they can resolve the hostname.
**Shardok must bind to `0.0.0.0:40042`** to accept connections from Eagle running in a separate container. This is configured in `ServerConfiguration.cpp`:
```cpp
// Default to 0.0.0.0 for container networking (accepts connections from any interface)
{ServerConfiguration::kEagleInterfaceGrpcAddress, "0.0.0.0:40042"}
```
This still works on the local Mac because `0.0.0.0` means "all interfaces", which includes the loopback interface that localhost uses.
### gRPC Plaintext for Internal Communication
By default, gRPC's `ManagedChannelBuilder` attempts TLS connections. Since Shardok runs in plaintext mode, Eagle must use `.usePlaintext()` for internal container-to-container communication:
```scala
// ServerSetupHelpers.scala
val channelBuilder = ManagedChannelBuilder
.forAddress(shardokInterfaceAddress, shardokInterfacePort)
.usePlaintext() // Required for internal Docker communication
```
TLS termination happens at nginx for external clients. Internal traffic between Eagle and Shardok stays within the Docker network and doesn't need encryption.
**Future consideration:** If Shardok moves to a separate droplet, traffic would traverse the network. Options:
1. Enable TLS between Eagle and Shardok (configure Shardok with certificates)
2. Use DigitalOcean VPC (private network, still unencrypted but isolated)
3. Use WireGuard/VPN tunnel between hosts
### Bazel Runfiles in Docker
Bazel's runfiles system locates resources relative to the executable using a manifest or directory structure that Bazel sets up at runtime. This doesn't exist in Docker containers.
**Solution:** Environment variable fallbacks in `FilesystemUtils.cpp`:
```cpp
// Check for Docker deployment env vars first
const char* resourcesPath = getenv("SHARDOK_RESOURCES_PATH");
if (resourcesPath != nullptr) {
return string(resourcesPath) + "/";
}
// Fall back to Bazel runfiles for local development
return rLocation + "/src/main/resources/net/eagle0/shardok/";
```
The Docker Compose configuration sets these:
```yaml
environment:
SHARDOK_RESOURCES_PATH: "/app/resources"
SHARDOK_MAPS_PATH: "/app/resources/maps"
```
This allows the same binary to work in both environments:
- **Local dev:** No env var → uses Bazel runfiles
- **Docker:** Env var set → uses `/app/resources/` directly
---
## Build Pipeline
### Build Artifacts
+2 -54
View File
@@ -1,7 +1,7 @@
{
"__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL",
"__INPUT_ARTIFACTS_HASH": -1064460283,
"__RESOLVED_ARTIFACTS_HASH": -1574144850,
"__INPUT_ARTIFACTS_HASH": 289080209,
"__RESOLVED_ARTIFACTS_HASH": -131178107,
"conflict_resolution": {
"com.google.guava:failureaccess:1.0.1": "com.google.guava:failureaccess:1.0.2",
"com.squareup.okio:okio:2.10.0": "com.squareup.okio:okio:3.6.0",
@@ -48,12 +48,6 @@
},
"version": "2.12.7"
},
"com.github.stephenc.jcip:jcip-annotations": {
"shasums": {
"jar": "4fccff8382aafc589962c4edb262f6aa595e34f1e11e61057d1c6a96e8fc7323"
},
"version": "1.0-1"
},
"com.google.android:annotations": {
"shasums": {
"jar": "ba734e1e84c09d615af6a09d33034b4f0442f8772dec120efb376d86a565ae15"
@@ -162,12 +156,6 @@
},
"version": "1.4.2"
},
"com.nimbusds:nimbus-jose-jwt": {
"shasums": {
"jar": "12ae4a3a260095d7aeba2adea7ae396e8b9570db8b7b409e09a824c219cc0444"
},
"version": "9.37.3"
},
"com.squareup.okhttp3:okhttp": {
"shasums": {
"jar": "b1050081b14bb7a3a7e55a4d3ef01b5dcfabc453b4573a4fc019767191d5f4e0"
@@ -822,9 +810,6 @@
"org.checkerframework:checker-qual",
"org.ow2.asm:asm"
],
"com.nimbusds:nimbus-jose-jwt": [
"com.github.stephenc.jcip:jcip-annotations"
],
"com.squareup.okhttp3:okhttp": [
"com.squareup.okio:okio",
"org.jetbrains.kotlin:kotlin-stdlib-jdk8"
@@ -1370,9 +1355,6 @@
"com.fasterxml.jackson.databind.type",
"com.fasterxml.jackson.databind.util"
],
"com.github.stephenc.jcip:jcip-annotations": [
"net.jcip.annotations"
],
"com.google.android:annotations": [
"android.annotation"
],
@@ -1518,38 +1500,6 @@
"com.google.truth:truth": [
"com.google.common.truth"
],
"com.nimbusds:nimbus-jose-jwt": [
"com.nimbusds.jose",
"com.nimbusds.jose.crypto",
"com.nimbusds.jose.crypto.bc",
"com.nimbusds.jose.crypto.factories",
"com.nimbusds.jose.crypto.impl",
"com.nimbusds.jose.crypto.opts",
"com.nimbusds.jose.crypto.utils",
"com.nimbusds.jose.jca",
"com.nimbusds.jose.jwk",
"com.nimbusds.jose.jwk.gen",
"com.nimbusds.jose.jwk.source",
"com.nimbusds.jose.mint",
"com.nimbusds.jose.proc",
"com.nimbusds.jose.produce",
"com.nimbusds.jose.shaded.gson",
"com.nimbusds.jose.shaded.gson.annotations",
"com.nimbusds.jose.shaded.gson.internal",
"com.nimbusds.jose.shaded.gson.internal.bind",
"com.nimbusds.jose.shaded.gson.internal.bind.util",
"com.nimbusds.jose.shaded.gson.internal.reflect",
"com.nimbusds.jose.shaded.gson.internal.sql",
"com.nimbusds.jose.shaded.gson.reflect",
"com.nimbusds.jose.shaded.gson.stream",
"com.nimbusds.jose.util",
"com.nimbusds.jose.util.cache",
"com.nimbusds.jose.util.events",
"com.nimbusds.jose.util.health",
"com.nimbusds.jwt",
"com.nimbusds.jwt.proc",
"com.nimbusds.jwt.util"
],
"com.squareup.okhttp3:okhttp": [
"okhttp3",
"okhttp3.internal",
@@ -2387,7 +2337,6 @@
"com.fasterxml.jackson.core:jackson-annotations",
"com.fasterxml.jackson.core:jackson-core",
"com.fasterxml.jackson.core:jackson-databind",
"com.github.stephenc.jcip:jcip-annotations",
"com.google.android:annotations",
"com.google.api.grpc:proto-google-common-protos",
"com.google.auth:google-auth-library-credentials",
@@ -2406,7 +2355,6 @@
"com.google.protobuf:protobuf-java",
"com.google.re2j:re2j",
"com.google.truth:truth",
"com.nimbusds:nimbus-jose-jwt",
"com.squareup.okhttp3:okhttp",
"com.squareup.okhttp3:okhttp-sse",
"com.squareup.okhttp:okhttp",
-100
View File
@@ -1,100 +0,0 @@
events {
worker_connections 1024;
}
http {
# Logging
log_format grpc_json escape=json '{'
'"time":"$time_iso8601",'
'"client":"$remote_addr",'
'"uri":"$uri",'
'"status":$status,'
'"grpc_status":"$sent_http_grpc_status",'
'"request_time":$request_time,'
'"upstream_time":"$upstream_response_time"'
'}';
access_log /var/log/nginx/access.log grpc_json;
error_log /var/log/nginx/error.log warn;
# Rate limiting zone
limit_req_zone $binary_remote_addr zone=grpc_limit:10m rate=100r/s;
# Docker DNS resolver - re-resolve hostnames every 10s
# This prevents stale IP caching when containers restart
resolver 127.0.0.11 valid=10s ipv6=off;
# Upstream for Eagle gRPC server
upstream eagle_grpc {
server eagle:40032;
keepalive 100;
}
# HTTP server for Let's Encrypt challenge and redirect
server {
listen 80;
server_name prod.eagle0.net;
# Let's Encrypt challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect all other HTTP to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS server for gRPC
server {
listen 443 ssl;
http2 on;
server_name prod.eagle0.net;
# SSL certificates (managed by certbot)
ssl_certificate /etc/letsencrypt/live/prod.eagle0.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/prod.eagle0.net/privkey.pem;
# SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# gRPC proxy for Eagle service
location /net.eagle0.eagle.api.Eagle {
# Rate limiting
limit_req zone=grpc_limit burst=50 nodelay;
# gRPC proxy
grpc_pass grpc://eagle_grpc;
# Timeouts for long-running streams
grpc_read_timeout 1200s;
grpc_send_timeout 1200s;
grpc_socket_keepalive on;
# Error handling
error_page 502 = /error502grpc;
}
# Health check endpoint
location /health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
# gRPC error handling
location = /error502grpc {
internal;
default_type application/grpc;
add_header grpc-status 14;
add_header grpc-message "unavailable";
return 204;
}
}
}
+30 -268
View File
@@ -3,25 +3,10 @@
# generate_changelog.sh
#
# Generates a weekly changelog from merged PRs, uses Claude to create a synopsis,
# and sends an HTML email via Fastmail JMAP API.
# and opens an email draft in Mac Mail.
#
# Usage: ./scripts/generate_changelog.sh [--dry-run]
#
# Configuration files (in ~/.config/eagle0/):
# fastmail_token - API token (required)
# changelog_recipient - Email addresses, one per line (optional, defaults to sender)
#
# To set up:
# mkdir -p ~/.config/eagle0
# echo 'your-token' > ~/.config/eagle0/fastmail_token
# chmod 600 ~/.config/eagle0/fastmail_token
#
# # Optional: configure recipients (one per line, # for comments)
# cat > ~/.config/eagle0/changelog_recipient << EOF
# alice@example.com
# bob@example.com
# EOF
#
# The script tracks its last run using a git tag 'changelog-last-run'.
# On first run (no tag), it defaults to the previous Friday at 4pm.
@@ -34,56 +19,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TAG_NAME="changelog-last-run"
DRY_RUN=false
FASTMAIL_API="https://api.fastmail.com/jmap/api/"
CONFIG_DIR="$HOME/.config/eagle0"
TOKEN_FILE="$CONFIG_DIR/fastmail_token"
RECIPIENT_FILE="$CONFIG_DIR/changelog_recipient"
# Load API token from file or environment
load_api_token() {
# Environment variable takes precedence
if [[ -n "${FASTMAIL_API_TOKEN:-}" ]]; then
return 0
fi
# Try loading from config file
if [[ -f "$TOKEN_FILE" ]]; then
FASTMAIL_API_TOKEN=$(cat "$TOKEN_FILE" | tr -d '[:space:]')
if [[ -n "$FASTMAIL_API_TOKEN" ]]; then
echo "Loaded API token from $TOKEN_FILE"
export FASTMAIL_API_TOKEN
return 0
fi
fi
return 1
}
# Load recipient emails from config file (one per line)
# Returns JSON array fragment like: {"email": "a@b.com"}, {"email": "c@d.com"}
load_recipients_json() {
local recipients=""
if [[ -f "$RECIPIENT_FILE" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
# Skip empty lines and comments
line=$(echo "$line" | tr -d '[:space:]')
[[ -z "$line" || "$line" == \#* ]] && continue
if [[ -n "$recipients" ]]; then
recipients="$recipients, "
fi
recipients="$recipients{\"email\": \"$line\"}"
done < "$RECIPIENT_FILE"
fi
echo "$recipients"
}
# Get human-readable list of recipients
load_recipients_display() {
if [[ -f "$RECIPIENT_FILE" ]]; then
grep -v '^#' "$RECIPIENT_FILE" | grep -v '^[[:space:]]*$' | tr '\n' ', ' | sed 's/, $//'
fi
}
# Parse arguments
while [[ $# -gt 0 ]]; do
@@ -183,30 +118,23 @@ generate_synopsis() {
# Create a prompt file to avoid shell escaping issues
local prompt_file="/tmp/eagle0_prompt_$$.txt"
# Get repo URL for PR links
local repo_url=$(gh repo view --json url -q '.url')
cat > "$prompt_file" <<PROMPT_HEADER
cat > "$prompt_file" <<'PROMPT_HEADER'
You are summarizing changes for a weekly engineering update email.
Read the following list of merged PRs and create a concise synopsis grouped by theme/feature/area of the codebase.
Structure:
1. <h1> title (e.g., "Eagle0 Weekly Update")
2. <h2>BLUF</h2> (Bottom Line Up Front) - A short prose paragraph (2-4 sentences) highlighting the 1-3 most important changes this week and what to look for when testing. This should be conversational and help readers quickly understand what matters most.
3. Synopsis sections (<h2> headings with bullet point summaries)
4. <hr> divider
5. <h2>PR Details</h2> with the same groupings, but smaller (<h3> headings) and listing PR links
- Format each PR as: <a href="${repo_url}/pull/NUMBER">#NUMBER</a>: Title
Guidelines for the SYNOPSIS sections:
- Group related changes together under clear headings (use <h2> tags)
- Use bullet points (<ul><li>) for individual changes
Guidelines:
- Group related changes together under clear headings (ALL CAPS with a blank line before)
- Use "- " bullet points for individual changes
- Highlight any significant new features, breaking changes, or important fixes
- Keep the tone professional but accessible
- Don't include PR numbers in the synopsis - focus on what changed and why it matters
- Aim for a summary that takes 1-2 minutes to read
- Don't include PR numbers in the summary - focus on what changed and why it matters
IMPORTANT: Output valid HTML that can be used directly in an email body. Do NOT wrap in \`\`\`html code blocks - just output the raw HTML.
IMPORTANT: Output plain text only. No markdown, no HTML. Use simple formatting:
- ALL CAPS for section headers
- Dashes (-) for bullet points
- Blank lines between sections
Here are the merged PRs:
@@ -216,161 +144,32 @@ PROMPT_HEADER
echo "" >> "$prompt_file"
echo "Generate the synopsis now:" >> "$prompt_file"
# Use Claude CLI to generate the synopsis, wrapped in proper HTML with charset
local raw_output="/tmp/eagle0_raw_$$.html"
cat "$prompt_file" | claude --print > "$raw_output"
# Use Claude CLI to generate the synopsis (read from stdin)
cat "$prompt_file" | claude --print > "$output_file"
# Wrap in HTML document with UTF-8 charset
cat > "$output_file" <<'HTML_HEAD'
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
HTML_HEAD
cat "$raw_output" >> "$output_file"
echo "</body></html>" >> "$output_file"
rm -f "$prompt_file" "$raw_output"
rm -f "$prompt_file"
echo "Synopsis generated at: $output_file"
}
# Get Fastmail session info (account ID, identity ID, drafts mailbox ID)
get_fastmail_session() {
echo "Fetching Fastmail session info..." >&2
# Get session
local session=$(curl -s \
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
"https://api.fastmail.com/jmap/session")
# Extract account ID (first account)
FASTMAIL_ACCOUNT_ID=$(echo "$session" | jq -r '.primaryAccounts["urn:ietf:params:jmap:mail"]')
if [[ -z "$FASTMAIL_ACCOUNT_ID" || "$FASTMAIL_ACCOUNT_ID" == "null" ]]; then
echo "Error: Could not get Fastmail account ID. Check your API token." >&2
return 1
fi
echo "Account ID: $FASTMAIL_ACCOUNT_ID" >&2
# Get identity ID
local identity_response=$(curl -s \
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST \
-d "{
\"using\": [\"urn:ietf:params:jmap:core\", \"urn:ietf:params:jmap:mail\", \"urn:ietf:params:jmap:submission\"],
\"methodCalls\": [
[\"Identity/get\", {\"accountId\": \"$FASTMAIL_ACCOUNT_ID\"}, \"0\"]
]
}" \
"$FASTMAIL_API")
FASTMAIL_IDENTITY_ID=$(echo "$identity_response" | jq -r '.methodResponses[0][1].list[0].id')
FASTMAIL_FROM_EMAIL=$(echo "$identity_response" | jq -r '.methodResponses[0][1].list[0].email')
if [[ -z "$FASTMAIL_IDENTITY_ID" || "$FASTMAIL_IDENTITY_ID" == "null" ]]; then
echo "Error: Could not get Fastmail identity ID." >&2
return 1
fi
echo "Identity ID: $FASTMAIL_IDENTITY_ID (${FASTMAIL_FROM_EMAIL})" >&2
# Get drafts mailbox ID
local mailbox_response=$(curl -s \
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST \
-d "{
\"using\": [\"urn:ietf:params:jmap:core\", \"urn:ietf:params:jmap:mail\"],
\"methodCalls\": [
[\"Mailbox/query\", {\"accountId\": \"$FASTMAIL_ACCOUNT_ID\", \"filter\": {\"role\": \"drafts\"}}, \"0\"]
]
}" \
"$FASTMAIL_API")
FASTMAIL_DRAFTS_ID=$(echo "$mailbox_response" | jq -r '.methodResponses[0][1].ids[0]')
if [[ -z "$FASTMAIL_DRAFTS_ID" || "$FASTMAIL_DRAFTS_ID" == "null" ]]; then
echo "Error: Could not get Fastmail drafts mailbox ID." >&2
return 1
fi
echo "Drafts mailbox ID: $FASTMAIL_DRAFTS_ID" >&2
return 0
}
# Send email via Fastmail JMAP API
send_email_fastmail() {
# Create email in Mac Mail
create_email() {
local synopsis_file="$1"
local recipients_json="$2" # JSON array fragment: {"email": "a@b.com"}, {"email": "c@d.com"}
local since_date="$2"
local subject="Eagle0 Weekly Changelog - $(date +%Y-%m-%d)"
local html_body=$(cat "$synopsis_file" | jq -Rs .)
echo "Sending email via Fastmail JMAP API..."
echo "Creating email draft in Mac Mail..."
# Create the email and send it in one request
local response=$(curl -s \
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST \
-d "{
\"using\": [
\"urn:ietf:params:jmap:core\",
\"urn:ietf:params:jmap:mail\",
\"urn:ietf:params:jmap:submission\"
],
\"methodCalls\": [
[\"Email/set\", {
\"accountId\": \"$FASTMAIL_ACCOUNT_ID\",
\"create\": {
\"draft\": {
\"from\": [{\"email\": \"$FASTMAIL_FROM_EMAIL\"}],
\"to\": [$recipients_json],
\"subject\": \"$subject\",
\"mailboxIds\": {\"$FASTMAIL_DRAFTS_ID\": true},
\"keywords\": {\"\$draft\": true},
\"htmlBody\": [{\"partId\": \"body\", \"type\": \"text/html\"}],
\"bodyValues\": {
\"body\": {
\"charset\": \"utf-8\",
\"value\": $html_body
}
}
}
}
}, \"0\"],
[\"EmailSubmission/set\", {
\"accountId\": \"$FASTMAIL_ACCOUNT_ID\",
\"onSuccessDestroyEmail\": [\"#sendIt\"],
\"create\": {
\"sendIt\": {
\"emailId\": \"#draft\",
\"identityId\": \"$FASTMAIL_IDENTITY_ID\"
}
}
}, \"1\"]
]
}" \
"$FASTMAIL_API")
osascript <<EOF
set textContent to read POSIX file "$synopsis_file" as «class utf8»
# Check for errors
local error=$(echo "$response" | jq -r '.methodResponses[0][1].notCreated.draft.description // empty')
if [[ -n "$error" ]]; then
echo "Error creating email: $error" >&2
echo "Full response: $response" >&2
return 1
fi
tell application "Mail"
set newMessage to make new outgoing message with properties {subject:"$subject", content:textContent, visible:true}
activate
end tell
EOF
local send_error=$(echo "$response" | jq -r '.methodResponses[1][1].notCreated.sendIt.description // empty')
if [[ -n "$send_error" ]]; then
echo "Error sending email: $send_error" >&2
echo "Full response: $response" >&2
return 1
fi
echo "Email sent successfully"
echo "Email draft created"
}
# Update the tag to mark this run
@@ -393,34 +192,13 @@ main() {
echo "=== Eagle0 Weekly Changelog Generator ==="
echo ""
# Load API token (only required for actual send)
if [[ "$DRY_RUN" != "true" ]]; then
if ! load_api_token; then
echo "Error: No Fastmail API token found."
echo ""
echo "To create a token:"
echo "1. Go to Fastmail Settings -> Password & Security -> API tokens"
echo "2. Create a new token with 'Email submission' scope"
echo "3. Save it using one of these methods:"
echo ""
echo " Option A (recommended): Store in config file"
echo " mkdir -p ~/.config/eagle0"
echo " echo 'your-token' > ~/.config/eagle0/fastmail_token"
echo " chmod 600 ~/.config/eagle0/fastmail_token"
echo ""
echo " Option B: Set environment variable"
echo " export FASTMAIL_API_TOKEN='your-token'"
exit 1
fi
fi
# Get cutoff date
local cutoff_date=$(get_cutoff_date)
echo "Cutoff date: $cutoff_date"
# Create temp files
local pr_file="/tmp/eagle0_prs_$(date +%s).md"
local synopsis_file="/tmp/eagle0_synopsis_$(date +%s).html"
local synopsis_file="/tmp/eagle0_synopsis_$(date +%s).txt"
# Fetch PRs
if ! fetch_merged_prs "$cutoff_date" "$pr_file"; then
@@ -439,26 +217,10 @@ main() {
echo "=== DRY RUN - Synopsis content ==="
cat "$synopsis_file"
echo ""
echo "=== DRY RUN - Skipping email send and tag update ==="
echo "=== DRY RUN - Skipping email creation and tag update ==="
else
# Get Fastmail session info
if ! get_fastmail_session; then
echo "Failed to get Fastmail session info. Exiting."
exit 1
fi
# Determine recipients (from config file, or default to sender)
local recipients_json=$(load_recipients_json)
if [[ -z "$recipients_json" ]]; then
recipients_json="{\"email\": \"$FASTMAIL_FROM_EMAIL\"}"
echo "No recipients configured, sending to self ($FASTMAIL_FROM_EMAIL)"
else
local recipients_display=$(load_recipients_display)
echo "Sending to: $recipients_display"
fi
# Send email
send_email_fastmail "$synopsis_file" "$recipients_json"
# Create email
create_email "$synopsis_file" "$cutoff_date"
# Update tag for next run
update_tag
-149
View File
@@ -1,149 +0,0 @@
#!/bin/bash
#
# Setup script for Eagle0 production droplet
# Run this on a fresh DigitalOcean droplet (Ubuntu 24.04)
#
# Usage: curl -sSL https://raw.githubusercontent.com/nolen777/eagle0/main/scripts/setup_droplet.sh | sudo bash
#
set -euo pipefail
DOMAIN="${DOMAIN:-eagle0.net}"
DEPLOY_USER="${DEPLOY_USER:-deploy}"
APP_DIR="/opt/eagle0"
echo "=== Eagle0 Production Server Setup ==="
echo "Domain: ${DOMAIN}"
echo "Deploy user: ${DEPLOY_USER}"
echo ""
# Check if running as root
if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root (use sudo)"
exit 1
fi
echo "=== Updating system ==="
apt-get update
apt-get upgrade -y
echo "=== Installing Docker ==="
if ! command -v docker &> /dev/null; then
curl -fsSL https://get.docker.com | sh
systemctl enable docker
systemctl start docker
else
echo "Docker already installed"
fi
echo "=== Installing Docker Compose plugin ==="
apt-get install -y docker-compose-plugin
echo "=== Installing additional utilities ==="
apt-get install -y \
curl \
wget \
git \
netcat-openbsd \
jq \
htop \
unattended-upgrades
echo "=== Configuring automatic security updates ==="
cat > /etc/apt/apt.conf.d/20auto-upgrades << 'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
EOF
echo "=== Creating deploy user ==="
if ! id "${DEPLOY_USER}" &>/dev/null; then
useradd -m -s /bin/bash -G docker "${DEPLOY_USER}"
mkdir -p "/home/${DEPLOY_USER}/.ssh"
chmod 700 "/home/${DEPLOY_USER}/.ssh"
chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "/home/${DEPLOY_USER}/.ssh"
echo ""
echo "*** IMPORTANT: Add your SSH public key to /home/${DEPLOY_USER}/.ssh/authorized_keys ***"
echo ""
else
echo "User ${DEPLOY_USER} already exists"
# Ensure user is in docker group
usermod -aG docker "${DEPLOY_USER}"
fi
echo "=== Creating application directory ==="
mkdir -p "${APP_DIR}"/{nginx,certbot/conf,certbot/www,saves}
chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "${APP_DIR}"
echo "=== Configuring Docker registry authentication ==="
echo ""
echo "*** IMPORTANT: Run the following command to authenticate with DigitalOcean Container Registry: ***"
echo " docker login registry.digitalocean.com"
echo ""
echo "=== Creating systemd service ==="
cat > /etc/systemd/system/eagle0.service << EOF
[Unit]
Description=Eagle0 Game Servers
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=${APP_DIR}
ExecStart=/usr/bin/docker compose -f docker-compose.prod.yml up -d
ExecStop=/usr/bin/docker compose -f docker-compose.prod.yml down
User=${DEPLOY_USER}
Group=${DEPLOY_USER}
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable eagle0
echo "=== Configuring firewall (UFW) ==="
if ! command -v ufw &> /dev/null; then
apt-get install -y ufw
fi
ufw default deny incoming
ufw default allow outgoing
ufw allow ssh
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
echo "=== Setting up log rotation ==="
cat > /etc/logrotate.d/eagle0 << EOF
/var/log/eagle0/*.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
create 0640 ${DEPLOY_USER} ${DEPLOY_USER}
sharedscripts
}
EOF
mkdir -p /var/log/eagle0
chown "${DEPLOY_USER}:${DEPLOY_USER}" /var/log/eagle0
echo ""
echo "=== Setup Complete ==="
echo ""
echo "Next steps:"
echo "1. Add SSH public key to /home/${DEPLOY_USER}/.ssh/authorized_keys"
echo "2. Copy docker-compose.prod.yml to ${APP_DIR}/"
echo "3. Copy nginx/nginx.conf to ${APP_DIR}/nginx/"
echo "4. Create .env file in ${APP_DIR}/ with OPENAI_API_KEY"
echo "5. Run: docker login registry.digitalocean.com"
echo "6. Get SSL certificate: (see init_ssl.sh)"
echo "7. Start services: systemctl start eagle0"
echo ""
echo "Server IP: $(curl -s ifconfig.me)"
echo ""
@@ -26,13 +26,6 @@ namespace fs = std::filesystem;
static string rLocation;
auto rloc(const string& execPath) -> string {
// First check for environment variable override for Docker deployment
const char* resourcesPath = getenv("SHARDOK_RESOURCES_PATH");
if (resourcesPath != nullptr) {
return ""; // Return empty so StaticShardokFilesDirectory uses env var directly
}
// Fall back to Bazel runfiles for development
string error;
const std::unique_ptr<Runfiles> runfiles(Runfiles::Create(execPath, &error));
@@ -65,14 +58,10 @@ auto FilesystemUtils::FileExistsAtPath(const string& path) -> bool { return fs::
auto FilesystemUtils::StaticEagle0FilesDirectory() -> string { return "/usr/local/share/eagle0/"; }
auto FilesystemUtils::StaticShardokFilesDirectory() -> string {
const char* resourcesPath = getenv("SHARDOK_RESOURCES_PATH");
if (resourcesPath != nullptr) { return string(resourcesPath) + "/"; }
return rLocation + "/src/main/resources/net/eagle0/shardok/";
}
auto FilesystemUtils::MapFilesDirectory() -> string {
const char* mapsPath = getenv("SHARDOK_MAPS_PATH");
if (mapsPath != nullptr) { return string(mapsPath) + "/"; }
return StaticShardokFilesDirectory() + "maps/";
}
@@ -7,7 +7,6 @@
#include <algorithm>
#include <bit>
#include <cstdint>
#include <cstdlib>
#define ITERABLE_BITSET_INDEX_CHECKS false
@@ -9,7 +9,6 @@
#include "MapUtils.hpp"
#include <algorithm>
#include <stdexcept>
static inline std::string StringForKey(
const std::unordered_map<std::string, std::string>& map,
@@ -9,7 +9,6 @@
#ifndef byte_vector_h
#define byte_vector_h
#include <cstdint>
#include <cstring>
#include <fstream>
#include <sstream>
@@ -4,7 +4,6 @@
#include "TranspositionTable.hpp"
#include <cinttypes>
#include <cstdio>
#include <cstring>
@@ -102,10 +101,10 @@ void TranspositionTable::clear() {
void TranspositionTable::printStats() const {
printf("TranspositionTable Stats:\n");
printf(" Probes: %" PRIu64 "\n", stats.probes.load());
printf(" Hits: %" PRIu64 " (%.1f%%)\n", stats.hits.load(), stats.hitRate());
printf(" Stores: %" PRIu64 "\n", stats.stores.load());
printf(" Collisions: %" PRIu64 "\n", stats.collisions.load());
printf(" Probes: %llu\n", stats.probes.load());
printf(" Hits: %llu (%.1f%%)\n", stats.hits.load(), stats.hitRate());
printf(" Stores: %llu\n", stats.stores.load());
printf(" Collisions: %llu\n", stats.collisions.load());
printf(" Table size: %zu entries (%.1f MB)\n",
TABLE_SIZE,
(TABLE_SIZE * sizeof(TTEntry)) / (1024.0 * 1024.0));
@@ -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";
@@ -24,6 +24,9 @@ using std::scoped_lock;
using std::string;
using std::unique_lock;
using std::weak_ptr;
using std::chrono::duration;
static constexpr duration kWaitForUpdatesDuration = std::chrono::milliseconds(5000);
using net::eagle0::shardok::common::GameStatus;
@@ -275,9 +278,15 @@ auto ShardokGameController::GetUpdates(const int64_t startingActionId) -> AllUpd
awrs = engine->GetGameHistory(startingActionId);
incomingRegistrations--;
// Note: Previously this had a 5-second wait for AI players to support long-polling.
// With streaming (WaitForUpdatesAndPush), the caller already waits for updates,
// so this wait is no longer needed. GetGameStatus is deprecated in favor of streaming.
// If the current player is an AI, wait for some results to post. Otherwise go ahead and
// return, we might be telling the caller about available commands.
if (awrs.empty() && !engine->GameIsOver() &&
engine->GetPlayerInfos()[engine->GetCurrentPlayerId()].is_ai()) {
updateCondition.wait_for(guard, kWaitForUpdatesDuration);
incomingRegistrations++;
awrs = engine->GetGameHistory(startingActionId);
incomingRegistrations--;
}
updates.mainResults.reserve(awrs.size());
std::ranges::transform(
@@ -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
@@ -322,6 +322,23 @@ void EagleInterfaceImpl::PopulateGameStatusResponse(
}
}
auto EagleInterfaceImpl::GetGameStatus(
ServerContext * /*context*/,
const GameStatusRequest *request,
GameStatusResponse *response) -> Status {
shared_ptr<ShardokGameController> controller;
try {
controller = ControllerForGame(request->game_id(), request->game_setup_info());
} catch (NewGameException &e) { return e.GetStatus(); }
PopulateGameStatusResponse(
controller,
request->game_setup_info().known_result_count(),
response);
return Status::OK;
}
auto EagleInterfaceImpl::GetHexMap(
ServerContext * /*context*/,
const HexMapRequest *request,
@@ -31,6 +31,7 @@ namespace shardok {
using grpc::ServerContext;
using grpc::Status;
using net::eagle0::common::GameSetupInfo;
using net::eagle0::common::GameStatusRequest;
using net::eagle0::common::GameStatusResponse;
using net::eagle0::common::GameSubscriptionRequest;
using net::eagle0::common::HexMapNamesRequest;
@@ -67,6 +68,10 @@ public:
ServerContext* context,
const PlacementCommandsRequest* request,
GameStatusResponse* response) -> Status override;
auto GetGameStatus(
ServerContext* context,
const GameStatusRequest* request,
GameStatusResponse* response) -> Status override;
auto GetHexMap(ServerContext* context, const HexMapRequest* request, HexMapResponse* response)
-> Status override;
@@ -8,7 +8,6 @@
#include "ServerConfiguration.hpp"
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <iostream>
@@ -31,16 +30,9 @@ using std::unordered_map;
unordered_map<string, string> DefaultValues();
unordered_map<string, string> DefaultValues() {
// Check environment variables for Docker deployment
// Docker containers should set SHARDOK_EAGLE_INTERFACE_ADDRESS=0.0.0.0:40042
// to listen on all interfaces for container networking
const char* eagleInterfaceAddr = getenv("SHARDOK_EAGLE_INTERFACE_ADDRESS");
const char* shardokAddr = getenv("SHARDOK_GRPC_ADDRESS");
return unordered_map<string, string>{
{ServerConfiguration::kShardokGrpcAddress, shardokAddr ? shardokAddr : "localhost"},
{ServerConfiguration::kEagleInterfaceGrpcAddress,
eagleInterfaceAddr ? eagleInterfaceAddr : "localhost:40042"}};
{ServerConfiguration::kShardokGrpcAddress, "localhost"},
{ServerConfiguration::kEagleInterfaceGrpcAddress, "localhost"}};
}
ServerConfiguration::ServerConfiguration(const string& filePath) {
@@ -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)
@@ -77,14 +77,11 @@ public class AuthInterceptor : Interceptor {
};
public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public TMP_Dropdown environmentDropdown;
public TMP_InputField urlField;
public TMP_InputField nameField;
public TMP_InputField passwordField;
public TMP_Dropdown resolutionDropdown;
[Header("Status Display")]
public TextMeshProUGUI connectionStatusText;
public GameObject connectionPanel;
public GameObject gameSelectionPanel;
public GameObject customBattlePanel;
@@ -115,22 +112,11 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public ClientPregeneratedText clientPregeneratedText;
const String BaseDomain = "eagle0.net";
const String EnvironmentKey = "environmentKey";
const String DefaultUrl = "eagle0.net";
const String URLKey = "urlKey";
const String NameKey = "nameKey";
const String PasswordKey = "passwordKey";
// Environment options: index 0 = prod., index 1 = qa., index 2 = (none)
private static readonly List<string> EnvironmentOptions = new() { "prod.", "qa.", "" };
private static readonly List<string> EnvironmentDisplayNames =
new() { "prod.", "qa.", "(none)" };
private string GetUrlFromEnvironment() {
int envIndex = environmentDropdown.value;
string prefix = EnvironmentOptions[envIndex];
return prefix + BaseDomain;
}
public void ReceiveLobbyUpdate(LobbyResponse lobbyResponse) {
_handleLobbyResponse(lobbyResponse);
}
@@ -147,11 +133,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
resolutionDropdown.AddOptions(resolutions.Select(r => $"{r.width} x {r.height}").ToList());
resolutionDropdown.value = resolutions.ToList().IndexOf(currentResolution);
// Set up environment dropdown
environmentDropdown.ClearOptions();
environmentDropdown.AddOptions(EnvironmentDisplayNames);
environmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
urlField.text = PlayerPrefs.GetString(URLKey, DefaultUrl);
nameField.text = PlayerPrefs.GetString(NameKey, "sample_name");
passwordField.text = PlayerPrefs.GetString(PasswordKey, "sample_password");
@@ -165,78 +147,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
customBattlePanel.gameObject.SetActive(false);
errorHandler.gameObject.SetActive(true);
// Initialize status text
UpdateConnectionStatus();
}
private float _statusUpdateTimer = 0f;
private const float StatusUpdateInterval = 0.5f;
void Update() {
// Only update status periodically and when connection panel is visible
if (!connectionPanel.activeInHierarchy) return;
_statusUpdateTimer += Time.deltaTime;
if (_statusUpdateTimer >= StatusUpdateInterval) {
_statusUpdateTimer = 0f;
UpdateConnectionStatus();
}
}
private void UpdateConnectionStatus() {
if (connectionStatusText == null) return;
// No connection yet - show nothing
if (_persistentClientConnection == null) {
connectionStatusText.text = "";
return;
}
var circuitState = _persistentClientConnection.CircuitBreaker.CurrentState;
var connState = _persistentClientConnection.CurrentState;
// Circuit breaker takes precedence
if (circuitState == eagle.ConnectionCircuitBreaker.State.Open) {
var nextTest = _persistentClientConnection.CircuitBreaker.NextTestAttempt;
if (nextTest.HasValue) {
var timeUntilTest = nextTest.Value - DateTime.UtcNow;
if (timeUntilTest.TotalSeconds > 0) {
int seconds = Math.Min(10, (int)Math.Ceiling(timeUntilTest.TotalSeconds));
connectionStatusText.text =
$"<color=red>●</color> Server unavailable. Retry in {seconds}s";
return;
}
}
connectionStatusText.text = "<color=red>●</color> Server unavailable";
return;
}
if (circuitState == eagle.ConnectionCircuitBreaker.State.HalfOpen) {
connectionStatusText.text = "<color=yellow>●</color> Testing connection...";
return;
}
// Normal connection states
connectionStatusText.text = connState switch {
eagle.ConnectionState.Connected => "<color=green>●</color> Connected",
eagle.ConnectionState.Connecting => "<color=yellow>●</color> Connecting...",
eagle.ConnectionState.Disconnected => "<color=red>●</color> Disconnected",
eagle.ConnectionState.Reconnecting => GetReconnectingText(),
eagle.ConnectionState.SubscriptionPending => "<color=yellow>●</color> Subscribing...",
_ => ""
};
}
private string GetReconnectingText() {
var nextAttempt = _persistentClientConnection?.NextReconnectAttempt;
if (!nextAttempt.HasValue) return "<color=yellow>●</color> Reconnecting...";
var timeUntilRetry = nextAttempt.Value - DateTime.UtcNow;
if (timeUntilRetry.TotalSeconds <= 0) { return "<color=yellow>●</color> Reconnecting..."; }
int seconds = Math.Min(10, (int)Math.Ceiling(timeUntilRetry.TotalSeconds));
return $"<color=orange>●</color> Retry in {seconds}s";
}
public void EditorButtonClicked() { SceneManager.LoadScene("Map Editor"); }
@@ -357,7 +267,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
private void _createConnection() {
PlayerPrefs.SetInt(EnvironmentKey, environmentDropdown.value);
PlayerPrefs.SetString(URLKey, urlField.text);
PlayerPrefs.SetString(NameKey, nameField.text);
PlayerPrefs.SetString(PasswordKey, passwordField.text);
@@ -370,8 +280,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
// Initialize cancellation token for thread management
_cancellationTokenSource = new CancellationTokenSource();
string url = GetUrlFromEnvironment();
eagleConnection = new EagleConnection(nameField.text, passwordField.text, url);
eagleConnection = new EagleConnection(nameField.text, passwordField.text, urlField.text);
_persistentClientConnection = new PersistentClientConnection(
eagleConnection.EagleGrpcClient,
eagleConnection.credentials,
@@ -1,8 +1,6 @@
using System;
using System.Threading;
#if UNITY_EDITOR
using UnityEditor;
#endif
using UnityEngine;
public class ConnectionKiller : MonoBehaviour {
@@ -12,10 +10,6 @@ public class ConnectionKiller : MonoBehaviour {
public void OnApplicationQuit() { ShutdownAll(); }
#if UNITY_EDITOR
void OnEnable() { EditorApplication.playModeStateChanged += OnPlayModeStateChanged; }
void OnDisable() { EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; }
void OnPlayModeStateChanged(PlayModeStateChange state) {
if (state == PlayModeStateChange.ExitingPlayMode) { ShutdownAll(); }
}
@@ -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,37 +405,16 @@ namespace eagle {
public void UpdateResultCounts(GameUpdate update) {
switch (update.GameUpdateDetailsCase) {
case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
var newCount = update.ActionResultResponse.UnfilteredResultCountAfter;
lock (_resultCountLock) {
var oldCount = _lastUnfilteredResultCount;
// Only update if new count is higher. The server sends 0 for
// intermediate chunks when splitting large result sets, which we
// should ignore to prevent false sync mismatches.
if (newCount > oldCount) {
_lastUnfilteredResultCount = newCount;
_connectionLogger.LogLine(
$"[RESULT_COUNT] Updated count {oldCount} -> {newCount}");
} else if (newCount < oldCount && newCount != 0) {
// Unexpected: count decreased (but not to 0, which is chunking)
_connectionLogger.LogLine(
$"[RESULT_COUNT] WARNING: Ignoring count decrease {oldCount} -> {newCount}");
}
_lastUnfilteredResultCount =
update.ActionResultResponse.UnfilteredResultCountAfter;
}
break;
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
foreach (var response in update.ShardokActionResultResponse
.ShardokGameResponses) {
var shardokId = response.ShardokGameId;
var newShardokCount = response.NewResultViewCount;
var oldShardokCount = _shardokResultCounts.GetValueOrDefault(shardokId, 0);
// Only update if new count is higher (count should only increase)
if (newShardokCount > oldShardokCount) {
_shardokResultCounts[shardokId] = newShardokCount;
} else if (newShardokCount < oldShardokCount) {
_connectionLogger.LogLine(
$"[SHARDOK_COUNT] WARNING: Ignoring count decrease for {shardokId}: {oldShardokCount} -> {newShardokCount}");
}
_shardokResultCounts[response.ShardokGameId] = response.NewResultViewCount;
}
break;
}
@@ -57,8 +57,7 @@ namespace eagle.Notifications {
{ BreakAllianceAcceptedDetails, BreakAllianceAcceptedNotificationGenerator.Generator },
{ VassalExiledDetails, VassalExiledDetailsNotificationGenerator.Generator },
{ WithdrewForTruce, WithdrewForTruceDetailsNotificationGenerator.Generator },
{ ProfessionGainedDetails, ProfessionGainedDetailsNotificationGenerator.Generator },
{ NewFactionHeadDetails, NewFactionHeadDetailsNotificationGenerator.Generator }
{ ProfessionGainedDetails, ProfessionGainedDetailsNotificationGenerator.Generator }
};
public static IEnumerable<Notification> GenerateNotifications(
@@ -1,98 +0,0 @@
using System;
using System.Collections.Generic;
using Net.Eagle0.Eagle.Common;
using Net.Eagle0.Eagle.Views;
namespace eagle.Notifications.ARNNotifications {
using ProvinceId = Int32;
using HeroId = Int32;
public static class NewFactionHeadDetailsNotificationGenerator {
public static readonly ARNNotificationGenerator Generator = GenerateNotifications;
private static readonly Random Random = new();
private static readonly List<string> NotificationTitles = new() {
"New Leadership",
"Succession",
"A New Era",
"Rise to Power",
"The Mantle Passes",
"Heir Ascendant",
"Crown of Command",
"Leadership Transferred",
"The Torch Passes",
"New Commander"
};
private static string GetNotificationTitle() {
return NotificationTitles[Random.Next(NotificationTitles.Count)];
}
private static ProvinceId? FindProvinceForHero(HeroId heroId, IGameModel model) {
foreach (var province in model.Provinces.Values) {
if (province.FullInfo?.RulingFactionHeroIds.Contains(heroId) == true) {
return province.Id;
}
}
return null;
}
private static IEnumerable<Notification> GenerateNotifications(
Net.Eagle0.Eagle.Common.Notification notification,
IGameModel currentModel) {
var details = notification.Details.NewFactionHeadDetails;
var newHeadHero = currentModel.Heroes[details.NewHeadHeroId];
var previousHeadHero =
currentModel.Heroes.TryGetValue(details.PreviousHeadHeroId, out var prev)
? prev
: null;
string textTemplate;
List<ProvinceId> affectedProvinces;
if (details.FactionId == currentModel.PlayerId) {
// Player's own faction - their new faction head
string previousHeadDescription =
previousHeadHero != null ? $"Following the death of {{PreviousHead}}, your"
: "Your";
textTemplate =
$"{previousHeadDescription} sworn {DisplayNames.SiblingDescription(newHeadHero.PronounGender)} {{NewHead}} now leads your faction.\n\n";
var heroProvinceId = FindProvinceForHero(details.NewHeadHeroId, currentModel);
affectedProvinces = heroProvinceId.HasValue
? new List<ProvinceId> { heroProvinceId.Value }
: currentModel.ProvincesForFaction(details.FactionId);
} else {
// Another faction
var factionName = currentModel.FactionName(details.FactionId);
if (previousHeadHero != null) {
textTemplate =
$"Following the death of {{PreviousHead}}, {{NewHead}} now leads {factionName}.\n\n";
} else {
textTemplate = $"{{NewHead}} now leads {factionName}.\n\n";
}
affectedProvinces = currentModel.ProvincesForFaction(details.FactionId);
}
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "NewHead", (newHeadHero.NameTextId, "A hero") }
};
if (previousHeadHero != null) {
heroPlaceholders["PreviousHead"] =
(previousHeadHero.NameTextId, "the previous leader");
}
var displayedHeroes = new List<HeroView> { newHeadHero };
if (previousHeadHero != null) { displayedHeroes.Add(previousHeadHero); }
yield return DynamicTextNotification.StreamingDynamicNotification(
title: GetNotificationTitle(),
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: displayedHeroes);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: e9afdd2068b294a29993f07b379eb9c3
@@ -44,18 +44,6 @@ namespace eagle {
private readonly Logger _remoteEagleClientLogger = Logger.GetLogger("ConnectionLogger");
private readonly Logger _timingsLogger = Logger.GetLogger("ConnectionLogger");
// Counter to diagnose duplicate logging - if logs show same seq# twice, two threads
// are processing same message. If seq# increments but lines doubled, Logger issue.
private int _logFlowSeq = 0;
/// <summary>Timestamped log for connection flow tracing.</summary>
private void LogFlow(string message) {
var seq = Interlocked.Increment(ref _logFlowSeq);
var ts = DateTime.UtcNow.ToString("HH:mm:ss.fff");
_remoteEagleClientLogger.LogLine($"[FLOW {ts}] #{seq} {message}");
}
private volatile bool _isConnecting = false;
private Timer _idleCheckTimer = null;
private Timer _heartbeatTimer = null;
@@ -134,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}");
@@ -207,8 +194,6 @@ 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}");
@@ -248,7 +233,6 @@ namespace eagle {
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}");
@@ -260,7 +244,6 @@ namespace eagle {
return true;
} else {
LogFlow($"SUBSCRIBE_ACK game={gameId} success=false error={ack.ErrorMessage}");
LogConnectionEvent(
"subscribe_ack_failed",
$"game={gameId}, error={ack.ErrorMessage}");
@@ -285,24 +268,23 @@ namespace eagle {
public async Task Connect() {
// Prevent concurrent connection attempts
if (_isConnecting) {
LogFlow($"Connect() SKIPPED already_connecting=true");
_remoteEagleClientLogger.LogLine($"Connect() skipped - already connecting");
return;
}
// Check circuit breaker
if (!_circuitBreaker.ShouldAttemptConnection()) {
LogFlow($"Connect() BLOCKED circuit={_circuitBreaker.CurrentState}");
_remoteEagleClientLogger.LogLine(
$"[CIRCUIT] Connection attempt blocked - circuit {_circuitBreaker.CurrentState}");
LogConnectionEvent("connect_blocked", $"circuit={_circuitBreaker.CurrentState}");
return;
}
_isConnecting = true;
try {
LogFlow($"Connect() START failures={_consecutiveFailures}");
_lastConnectAttempt = DateTime.UtcNow;
_currentState = _consecutiveFailures > 0 ? ConnectionState.Reconnecting
: ConnectionState.Connecting;
LogFlow($"State -> {_currentState}");
NextReconnectAttempt = null;
LogConnectionEvent("connect_attempt");
@@ -336,10 +318,8 @@ namespace eagle {
// Stream subscriptions OUTSIDE lock (can await)
// Set state to SubscriptionPending while waiting for acks
LogFlow($"Streaming {subscribersToStream.Count} subscriptions");
if (subscribersToStream.Any()) {
_currentState = ConnectionState.SubscriptionPending;
LogFlow($"State -> {_currentState}");
}
bool allSucceeded = true;
@@ -350,7 +330,6 @@ namespace eagle {
if (!allSucceeded) {
// Subscription failed - this is critical because the game won't receive
// updates. Trigger reconnect rather than proceeding to Connected state.
LogFlow("SUBSCRIBE_FAILED triggering reconnect");
LogConnectionEvent(
"subscribe_failed",
"Subscription failed - triggering reconnect");
@@ -374,7 +353,6 @@ namespace eagle {
_lastSuccessfulConnect = DateTime.UtcNow;
_consecutiveFailures = 0; // Reset backoff on successful connection
_currentState = ConnectionState.Connected;
LogFlow($"State -> {_currentState}");
_circuitBreaker.RecordSuccess();
LogConnectionEvent("connect_success");
@@ -525,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();
@@ -550,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) {
@@ -736,7 +706,6 @@ 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); }
@@ -759,63 +728,16 @@ namespace eagle {
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} countAfter={arResp.UnfilteredResultCountAfter} " +
$"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);
}
}
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();
var processTime = (DateTime.UtcNow - receivedTime).TotalMilliseconds;
if (processTime > 100.0) {
_timingsLogger.LogLine($"PROCESS {processTime} ms");
}
});
break;
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
var shardokResp = gameUpdate.ShardokActionResultResponse;
var shardokGameCount = shardokResp.ShardokGameResponses?.Count ?? 0;
// Log each Shardok game's sequence to detect missing results
foreach (var sgr in shardokResp.ShardokGameResponses) {
var resultCount = sgr.ActionResultViews?.Count ?? 0;
LogFlow($"UPDATE ShardokResult game={gameUpdate.GameId} " +
$"shardok={sgr.ShardokGameId} countAfter={sgr.NewResultViewCount} " +
$"results={resultCount}");
}
// Update result counts IMMEDIATELY on the gRPC thread, before enqueueing.
lock (this) {
if (_subscribers.TryGetValue(gameUpdate.GameId, out var sub2)) {
sub2.UpdateResultCounts(gameUpdate);
}
}
MainQueue.Q.Enqueue(async () => {
IClientConnectionSubscriber subscriber;
lock (this) {
@@ -838,7 +760,6 @@ namespace eagle {
}
private async void HandleStreamingCall() {
LogFlow("HandleStreamingCall STARTED");
try {
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> sc;
CancellationToken threadToken;
@@ -954,10 +875,6 @@ 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) {
@@ -1057,11 +974,7 @@ namespace eagle {
AutoReset = true,
Interval = 5000 // Check every 5 seconds
};
_idleCheckTimer.Elapsed += (sender, args) => {
// Log BEFORE callback to confirm timer is firing
Console.WriteLine($"[IDLE_TIMER] Elapsed at {DateTime.UtcNow:HH:mm:ss.fff}");
CheckForIdleTimeout();
};
_idleCheckTimer.Elapsed += (sender, args) => CheckForIdleTimeout();
_idleCheckTimer.Enabled = true;
}
@@ -1074,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;
@@ -1115,11 +1022,7 @@ namespace eagle {
StopHeartbeatTimer();
_heartbeatTimer =
new Timer { AutoReset = true, Interval = HeartbeatIntervalSeconds * 1000 };
_heartbeatTimer.Elapsed += (sender, args) => {
// Log BEFORE Task.Run to confirm timer is firing
Console.WriteLine($"[HEARTBEAT_TIMER] Elapsed at {DateTime.UtcNow:HH:mm:ss.fff}");
Task.Run(() => SendHeartbeat());
};
_heartbeatTimer.Elapsed += (sender, args) => Task.Run(() => SendHeartbeat());
_heartbeatTimer.Enabled = true;
}
@@ -1132,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 {
@@ -1171,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");
}
}
@@ -1186,7 +1076,6 @@ 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}");
@@ -1194,7 +1083,6 @@ namespace eagle {
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");
@@ -1238,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 } };
File diff suppressed because it is too large Load Diff
@@ -511,7 +511,6 @@ public class HexGrid : MonoBehaviour {
}
public void ClearCellLabels() {
if (cells == null) return;
for (int i = 0; i < width * height; i++) {
cells[i].UpperUnitInfoLabel.text = "";
cells[i].LowerUnitInfoLabel.text = "";
@@ -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"],
)
@@ -21,12 +21,15 @@ option java_outer_classname = "EagleInterface";
option objc_class_prefix = "E0G";
service ShardokInternalInterface {
// Server-side streaming for game updates
// Server-side streaming for game updates - replaces polling via GetGameStatus
rpc SubscribeToGame(GameSubscriptionRequest) returns (stream GameStatusResponse) {}
rpc PostCommand(PostCommandRequest) returns (GameStatusResponse) {}
rpc PostPlacementCommands(PlacementCommandsRequest) returns (GameStatusResponse) {}
// Deprecated: Use SubscribeToGame for streaming updates instead
rpc GetGameStatus(GameStatusRequest) returns (GameStatusResponse) {}
rpc GetHexMap(HexMapRequest) returns (HexMapResponse) {}
rpc GetHexMapNames(HexMapNamesRequest) returns (HexMapNamesResponse) {}
}
@@ -55,6 +58,11 @@ message PlacementCommandsRequest {
GameSetupInfo game_setup_info = 6;
}
message GameStatusRequest {
string game_id = 1;
GameSetupInfo game_setup_info = 2;
}
message GameStatusResponse {
string game_id = 1;
@@ -3,22 +3,6 @@ load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
load("@rules_proto//proto:defs.bzl", "proto_library")
load("@rules_scala//scala_proto:scala_proto.bzl", "scala_proto_library")
# Auth service for OAuth login
scala_proto_library(
name = "auth_scala_grpc",
visibility = [
"//src/main/scala/net/eagle0:__subpackages__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
deps = [":auth_grpc"],
)
proto_library(
name = "auth_grpc",
srcs = ["auth.proto"],
visibility = ["//visibility:public"],
)
scala_proto_library(
name = "eagle_scala_grpc",
visibility = [
@@ -258,7 +242,6 @@ go_proto_library(
compilers = ["@io_bazel_rules_go//proto:go_grpc"], # keep
importpath = "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/api/eagle", # keep
protos = [
":auth_grpc",
":available_command_proto",
":command_proto",
":eagle_grpc",
@@ -1,108 +0,0 @@
//
// Copyright 2025 Dan Crosby
//
syntax = "proto3";
package net.eagle0.eagle.api.auth;
option java_multiple_files = true;
option java_package = "net.eagle0.eagle.api.auth";
option java_outer_classname = "AuthProto";
option objc_class_prefix = "E0A";
// Authentication service for OAuth login
service Auth {
// Get OAuth URL to open in system browser
rpc GetOAuthUrl(GetOAuthUrlRequest) returns (GetOAuthUrlResponse) {}
// Exchange authorization code for JWT tokens
rpc ExchangeCode(ExchangeCodeRequest) returns (ExchangeCodeResponse) {}
// Set or update display name (requires valid JWT)
rpc SetDisplayName(SetDisplayNameRequest) returns (SetDisplayNameResponse) {}
// Refresh access token using refresh token
rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse) {}
// Get current user info (validates JWT)
rpc GetCurrentUser(GetCurrentUserRequest) returns (GetCurrentUserResponse) {}
// Logout - invalidates refresh token
rpc Logout(LogoutRequest) returns (LogoutResponse) {}
}
// OAuth provider enumeration
enum OAuthProvider {
OAUTH_PROVIDER_UNSPECIFIED = 0;
OAUTH_PROVIDER_DISCORD = 1;
OAUTH_PROVIDER_GOOGLE = 2;
}
// Request to initiate OAuth flow
message GetOAuthUrlRequest {
OAuthProvider provider = 1;
string redirect_uri = 2; // e.g., "eagle0://auth/callback"
}
message GetOAuthUrlResponse {
string auth_url = 1; // Full OAuth URL to open in system browser
string state = 2; // State parameter for CSRF protection
}
// Exchange authorization code for JWT
message ExchangeCodeRequest {
OAuthProvider provider = 1;
string authorization_code = 2;
string state = 3; // Must match state from GetOAuthUrlResponse
string redirect_uri = 4; // Must match exactly what was used in GetOAuthUrlRequest
}
message ExchangeCodeResponse {
string access_token = 1; // JWT for API access
string refresh_token = 2; // Long-lived token for getting new access tokens
int64 expires_at = 3; // Unix timestamp when access_token expires
UserInfo user = 4;
bool is_new_user = 5; // True if user needs to set display name
}
// User information returned from auth endpoints
message UserInfo {
string user_id = 1; // Internal Eagle0 user ID (UUID)
string display_name = 2; // Player-chosen display name (may be empty for new users)
string avatar_url = 3; // Profile picture URL from OAuth provider
OAuthProvider provider = 4;
}
// Set or update display name
message SetDisplayNameRequest {
string display_name = 1; // 3-20 characters, alphanumeric + underscore
}
message SetDisplayNameResponse {
bool success = 1;
string error_message = 2; // If not success: "Name taken", "Invalid characters", etc.
UserInfo user = 3;
}
// Refresh access token
message RefreshTokenRequest {
string refresh_token = 1;
}
message RefreshTokenResponse {
string access_token = 1;
int64 expires_at = 2;
}
// Get current user info
message GetCurrentUserRequest {}
message GetCurrentUserResponse {
UserInfo user = 1;
}
// Logout
message LogoutRequest {}
message LogoutResponse {}
@@ -284,12 +284,6 @@ message ProfessionGainedDetails {
Profession new_profession = 3;
}
message NewFactionHeadDetails {
int32 new_head_hero_id = 1;
int32 faction_id = 2;
int32 previous_head_hero_id = 3;
}
message NotificationDetails {
oneof sealed_value {
AllianceAcceptedDetails alliance_accepted_details = 30;
@@ -330,6 +324,5 @@ message NotificationDetails {
VassalExiledDetails vassal_exiled_details = 8;
WithdrewForTruceDetails withdrew_for_truce = 3;
ProfessionGainedDetails profession_gained_details = 35;
NewFactionHeadDetails new_faction_head_details = 39;
}
}
@@ -772,17 +772,6 @@ proto_library(
],
)
scala_proto_library(
name = "user_scala_proto",
deps = [":user_proto"],
)
proto_library(
name = "user_proto",
srcs = ["user.proto"],
deps = ["@com_google_protobuf//:timestamp_proto"],
)
go_proto_library(
name = "internal_go_proto",
importpath = "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/internal",
@@ -813,7 +802,6 @@ go_proto_library(
":shardok_results_proto",
":supplies_proto",
":unaffiliated_hero_proto",
":user_proto",
],
deps = [
"//src/main/protobuf/net/eagle0/common:common_go_proto",
@@ -53,6 +53,4 @@ message ChangedFaction {
.google.protobuf.Int32Value new_last_acted_province_id = 22;
bool clear_last_acted_province_id = 23;
.google.protobuf.StringValue new_name = 26;
}
@@ -72,7 +72,6 @@ message GeneratedTextRequestDetails {
PrisonerReleasedMessage prisoner_released_message = 32;
PrisonerExiledMessage prisoner_exiled_message = 33;
PrisonerReturnedMessage prisoner_returned_message = 34;
NewFactionHeadMessage new_faction_head_message = 35;
}
}
@@ -335,13 +334,3 @@ message PrisonerReturnedMessage {
int32 to_faction_id = 4;
int32 province_id = 5;
}
message NewFactionHeadMessage {
int32 new_head_hero_id = 1;
int32 faction_id = 2;
string previous_faction_name = 3;
// If present, the faction is being renamed to this
string new_faction_name = 4;
// The previous faction head who was executed
int32 previous_head_hero_id = 5;
}
@@ -58,8 +58,4 @@ message Hero {
repeated EventForHeroBackstory new_backstory_events = 29;
.net.eagle0.eagle.common.Gender pronoun_gender = 27;
// The name of the faction this hero would lead, if they led one.
// Populated for "great person" heroes from the heroes TSV; empty for others.
string led_faction_name = 30;
}
@@ -1,65 +0,0 @@
//
// Copyright 2025 Dan Crosby
//
syntax = "proto3";
package net.eagle0.eagle.internal.user;
import "google/protobuf/timestamp.proto";
option java_multiple_files = true;
option java_package = "net.eagle0.eagle.internal.user";
// Internal user record stored in users.pb
message User {
string user_id = 1; // UUID generated by Eagle
string display_name = 2; // Unique, player-chosen name
string display_name_lower = 3; // Lowercase version for uniqueness checks
// OAuth identities (a user can have multiple linked providers)
repeated OAuthIdentity oauth_identities = 4;
google.protobuf.Timestamp created_at = 5;
google.protobuf.Timestamp last_login_at = 6;
string avatar_url = 7;
// Admin flag - allows impersonating other users for debugging
// Set manually in the database, not via API
bool is_admin = 8;
}
// OAuth identity from a provider
message OAuthIdentity {
string provider = 1; // "discord" or "google"
string provider_user_id = 2; // ID from the OAuth provider
string email = 3; // Email from provider (for account recovery)
google.protobuf.Timestamp linked_at = 4;
}
// Container for all users (stored as single file)
message UserDatabase {
repeated User users = 1;
// Indexes for fast lookup (provider:provider_user_id -> user_id)
map<string, string> oauth_index = 2;
// display_name_lower -> user_id
map<string, string> display_name_index = 3;
}
// Refresh token record
message RefreshToken {
string token_id = 1; // UUID
string user_id = 2;
string token_hash = 3; // SHA-256 hash of actual token
google.protobuf.Timestamp created_at = 4;
google.protobuf.Timestamp expires_at = 5;
bool revoked = 6;
}
// Container for refresh tokens
message RefreshTokenDatabase {
repeated RefreshToken tokens = 1;
}
@@ -43,6 +43,4 @@ message FactionViewDiff {
repeated .net.eagle0.eagle.common.DiplomacyOffer removed_incoming_diplomacy_offers = 13;
.google.protobuf.Int32Value new_focus_province_id = 11;
.google.protobuf.StringValue new_name = 14;
}
@@ -7,11 +7,7 @@ object ApiKeys {
private val apiKeyFilePath =
"src/main/scala/net/eagle0/common/llm_integration/api_keys.txt"
// Environment variable names
private val openAIEnvVar = "OPENAI_API_KEY"
private val anthropicEnvVar = "ANTHROPIC_API_KEY"
// File key names
// These must have valid values set in api_keys.txt
private val openAIKeyName = "openai_api_key"
private val anthropicKeyName = "anthropic_api_key"
@@ -27,31 +23,25 @@ object ApiKeys {
)
}.toMap
}.getOrElse {
Map.empty // Return empty map if file doesn't exist; we'll check env vars
throw new ApiKeyException(
"Failed to read api_keys.txt. Make sure you have copied api_keys_template.txt to api_keys.txt and set the keys."
)
}
private val dictionary = readDictionary()
private def getKey(key: String, envVar: String): String =
// First check environment variable
Option(System.getenv(envVar)).filter(_.nonEmpty) match {
case Some(value) => value
case None =>
// Fall back to file-based dictionary
dictionary.get(key) match {
case None =>
throw new ApiKeyException(
s"$key not found. Set $envVar environment variable or add to $apiKeyFilePath."
)
case Some(value) if value.startsWith("your_") =>
throw new ApiKeyException(
s"API key for $key is not set. Set $envVar environment variable or update $apiKeyFilePath."
)
case Some(value) => value
}
private def getKey(key: String): String =
dictionary.get(key) match {
case None =>
throw new ApiKeyException(s"$key not found")
case Some(value) if value.startsWith("your_") =>
throw new ApiKeyException(
s"API key for $key is not set. Please set it in $apiKeyFilePath."
)
case Some(value) => value
}
lazy val openAI: String = getKey(openAIKeyName, openAIEnvVar)
lazy val openAI: String = getKey(openAIKeyName)
lazy val anthropic: String = getKey(anthropicKeyName, anthropicEnvVar)
lazy val anthropic: String = getKey(anthropicKeyName)
}
+1 -8
View File
@@ -7,26 +7,19 @@ scala_binary(
visibility = ["//visibility:public"],
deps = [
":eagle_pkg",
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
"//src/main/protobuf/net/eagle0/eagle/api:eagle_scala_grpc",
"//src/main/protobuf/net/eagle0/eagle/internal:user_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/common:simple_timed_logger",
"//src/main/scala/net/eagle0/eagle/auth:jwt_service",
"//src/main/scala/net/eagle0/eagle/auth:oauth_service",
"//src/main/scala/net/eagle0/eagle/auth:user_service",
"//src/main/scala/net/eagle0/eagle/service",
"//src/main/scala/net/eagle0/eagle/service:auth_service",
"//src/main/scala/net/eagle0/eagle/service:authorization_interceptor",
"//src/main/scala/net/eagle0/eagle/service:custom_battle_manager",
"//src/main/scala/net/eagle0/eagle/service:exception_interceptor",
"//src/main/scala/net/eagle0/eagle/service:games_manager",
"//src/main/scala/net/eagle0/eagle/service:server_setup_helpers",
"//src/main/scala/net/eagle0/eagle/service/persistence:local_file_persister",
"//src/main/scala/net/eagle0/eagle/service/persistence:save_directory",
"//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",
# "@maven//:com_thesamet_scalapb_scalapb_runtime_3",
],
)
+1 -30
View File
@@ -6,11 +6,8 @@ import scala.util.Random
import io.grpc.{Server, ServerBuilder}
import net.eagle0.common.{FunctionalRandom, SeededRandom, SimpleTimedLogger}
import net.eagle0.eagle.api.auth.auth.AuthGrpc
import net.eagle0.eagle.api.eagle.EagleGrpc
import net.eagle0.eagle.auth.{JwtService, JwtServiceImpl, OAuthServiceImpl, UserServiceImpl}
import net.eagle0.eagle.service.*
import net.eagle0.eagle.service.persistence.{LocalFilePersister, SaveDirectory}
object Main {
import ServerSetupHelpers.*
@@ -95,13 +92,6 @@ object Main {
): Server = {
val serverBuilder = ServerBuilder.forPort(grpcPort)
// Initialize auth services
val jwtService: Option[JwtService] = JwtServiceImpl.fromEnvironment()
val authPersister = LocalFilePersister(SaveDirectory.saveDirectory)
val userService = new UserServiceImpl(authPersister)
val oauthService = new OAuthServiceImpl()
// Add Eagle game service
serverBuilder.addService(
EagleGrpc
.bindService(
@@ -109,27 +99,8 @@ object Main {
ExecutionContext.global
)
)
// Add Auth service (only if JWT is configured)
jwtService.foreach { jwt =>
serverBuilder.addService(
AuthGrpc
.bindService(
AuthServiceImpl(oauthService, userService, jwt),
ExecutionContext.global
)
)
SimpleTimedLogger.printLogger.logLine("OAuth auth service enabled")
}
if jwtService.isEmpty then {
SimpleTimedLogger.printLogger.logLine(
"OAuth auth service disabled (no JWT keys configured). Using Basic Auth only."
)
}
serverBuilder.intercept(new ExceptionInterceptor)
serverBuilder.intercept(new AuthorizationInterceptor(jwtService))
serverBuilder.intercept(new AuthorizationInterceptor)
serverBuilder.build
}
@@ -151,7 +151,7 @@ case class AIClient(
(actingFactionId, gameState, availableCommands, functionalRandom) =>
AttackDecisionCommandChooser.attackDecisionSelectedCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands,
functionalRandom
),
@@ -195,7 +195,7 @@ case class AIClient(
AlmsCommandSelector
.chosenAlmsForSupportWithMinimumCommandForProvince(
actingFactionId = fid,
gameState = GameStateConverter.fromProto(gs),
gameState = gs,
availableCommands = acs,
minimumFood = 0,
provinceId = opac.provinceId
+6 -18
View File
@@ -74,8 +74,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:available_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:province_gold_surplus_calculator",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:trust_for_diplomacy",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -107,7 +105,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 +144,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 +180,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",
@@ -190,15 +187,14 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:available_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:expand_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:food_consumption_utils",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:fulfill_quests_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:improve_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:organize_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:province_gold_surplus_calculator",
"//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:legacy_hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
],
)
@@ -215,7 +211,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",
],
)
@@ -241,10 +236,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:available_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:ransom_offer_helpers",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
@@ -257,21 +249,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,
@@ -8,7 +8,6 @@ import net.eagle0.eagle.library.settings.MinChanceForAIInvite
import net.eagle0.eagle.library.util.command_choice_helpers.{ProvinceGoldSurplusCalculator, TrustForDiplomacy}
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.flatSelectionForType
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.FactionId
object InvitationCommandSelector {
@@ -23,16 +22,16 @@ object InvitationCommandSelector {
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[DiplomacyAvailableCommand](acs = availableCommands) { diplomacyAvailableCommand =>
val nativeGameState = GameStateConverter.fromProto(gameState)
diplomacyAvailableCommand.options.collect { case io: InvitationOption => io }.filter { invitationOption =>
TrustForDiplomacy.meetsConditionsForInvitation(
actingFactionId = actingFactionId,
targetFactionId = invitationOption.targetFactionId,
gameState = nativeGameState
gameState = gameState
)
}.filter { invitationOption =>
ProvinceGoldSurplusCalculator.provinceGoldSurplus(
nativeGameState.provinces(diplomacyAvailableCommand.actingProvinceId)
diplomacyAvailableCommand.actingProvinceId,
gameState
) >= invitationOption.goldCost
}.map { invitationOption =>
InvitationOptionWithChance(
@@ -40,8 +39,7 @@ object InvitationCommandSelector {
acceptanceChance = ResolveDiplomacyCommandSelector.invitationAcceptanceChance(
actingFactionId,
invitationOption.targetFactionId,
nativeGameState.factions.values.toVector,
nativeGameState.provinces.values.toVector
gameState
)
)
}.maxByOption(_.acceptanceChance)
@@ -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,
@@ -19,6 +19,7 @@ import net.eagle0.eagle.library.util.command_choice_helpers.{
CommandChoiceHelpers,
CommandChooser,
ExpandCommandSelector,
FoodConsumptionUtils,
FulfillQuestsCommandSelector,
ImproveCommandSelector,
OrganizeCommandSelector,
@@ -28,12 +29,9 @@ import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSele
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChoiceHelpers.*
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.*
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
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
@@ -139,7 +137,7 @@ object MidGameAIClient {
) =>
val actingProvince = gameState.provinces(actingProvinceId)
val foodMonthsToHoldBack =
LegacyFoodConsumptionUtils.foodConsumptionMonthsToHold(
FoodConsumptionUtils.foodConsumptionMonthsToHold(
actingProvinceId,
gameState
)
@@ -271,7 +269,7 @@ object MidGameAIClient {
RandomState(
ImproveCommandSelector.chosenImproveCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands
),
functionalRandom
@@ -300,7 +298,7 @@ object MidGameAIClient {
RandomState(
ImproveCommandSelector.chosenImproveCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands
),
functionalRandom
@@ -347,18 +345,7 @@ object MidGameAIClient {
acs = availableCommands,
choosersAndReasons = Vector(
(
(
fid: FactionId,
gs: GameState,
acs: Vector[AvailableCommand],
fr: FunctionalRandom
) =>
ExpandCommandSelector.maybeChosenExpandCommand(
fid,
GameStateConverter.fromProto(gs),
acs,
fr
),
ExpandCommandSelector.maybeChosenExpandCommand,
"no hostile neighbors, expand"
),
(
@@ -375,7 +362,7 @@ object MidGameAIClient {
RandomState(
ImproveCommandSelector.chosenImproveCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands
),
functionalRandom
@@ -463,7 +450,7 @@ object MidGameAIClient {
RandomState(
ImproveCommandSelector.chosenImproveCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands
),
functionalRandom
@@ -518,7 +505,7 @@ object MidGameAIClient {
AttackCommandChooser.chosenAttackCommand(
actingFactionId,
GameStateConverter.fromProto(gs),
gs,
acs,
reconnedHeroCountFor,
reconnedBattalionsFor
@@ -538,13 +525,13 @@ object MidGameAIClient {
AlmsCommandSelector
.chosenAlmsForSupportCommand(
actingFactionId = actingFactionId,
gameState = GameStateConverter.fromProto(gameState),
gameState = gameState,
availableCommands = availableCommands
)
.orElse {
ImproveCommandSelector.chosenImproveCommand(
actingFactionId = actingFactionId,
gameState = GameStateConverter.fromProto(gameState),
gameState = gameState,
availableCommands = availableCommands
)
}
@@ -577,7 +564,7 @@ object MidGameAIClient {
RandomState(
AttackCommandChooser.chosenSupportAttackCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands
),
functionalRandom
@@ -627,7 +614,7 @@ object MidGameAIClient {
RandomState(
SeekMoreLeadersCommandChooser.maybeSeekMoreLeadersCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands
),
functionalRandom
@@ -803,7 +790,7 @@ object MidGameAIClient {
OrganizeCommandSelector
.chosenOrganizeCommandWithFoodSurplus(
actingFactionId = fid,
gameState = GameStateConverter.fromProto(gs),
gameState = gs,
availableCommands = acs,
extraFoodSurplus = foodSurplus(
gs,
@@ -905,7 +892,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,
@@ -39,12 +39,10 @@ import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSele
randomSelectionForType,
selectionForType
}
import net.eagle0.eagle.library.util.faction_utils.{FactionUtils, LegacyFactionUtils}
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.FactionId
object ResolveDiplomacyCommandSelector {
@@ -142,20 +140,6 @@ object ResolveDiplomacyCommandSelector {
- LegacyFactionUtils.prestige(invitedFid, gs)
- MinimumPrestigeDifferenceToInvite.doubleValue).floor.toInt.max(0)
/** Protoless version */
def invitationAcceptanceChance(
originatingFid: FactionId,
invitedFid: FactionId,
factions: Vector[FactionT],
provinces: Vector[ProvinceT]
): Int = {
val originatingFaction = factions.find(_.id == originatingFid).get
val invitedFaction = factions.find(_.id == invitedFid).get
(FactionUtils.prestige(originatingFaction, provinces)
- FactionUtils.prestige(invitedFaction, provinces)
- MinimumPrestigeDifferenceToInvite.doubleValue).floor.toInt.max(0)
}
private def resolveTruceOfferSelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
@@ -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(
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,
@@ -147,13 +144,14 @@ object SeekMoreLeadersCommandChooser {
// First try a hero that's already here
val desiredHero = SwornBrotherChooser.bestChoice(
leaderProvince.rulingFactionHeroIds.map(gameState.heroes),
faction.leaderIds
faction.leaders.toVector
)
desiredHero.flatMap { hero =>
SwearBrotherhoodCommandSelector
.chosenCommandForSpecificHero(
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = acs,
desiredHeroId = hero.id
)
@@ -163,6 +161,7 @@ object SeekMoreLeadersCommandChooser {
) {
CommandChoiceHelpers.chosenFeastCommandIfAvailable(
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = acs,
reason = s"want to make hero ${hero.id} a leader"
)
@@ -171,6 +170,7 @@ object SeekMoreLeadersCommandChooser {
.orElse {
HeroGiftCommandSelector.chosenCommandForSpecificHero(
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = acs,
heroId = hero.id,
reason = "want to make this hero a leader"
@@ -1,51 +0,0 @@
load("@rules_scala//scala:scala.bzl", "scala_library")
scala_library(
name = "jwt_service",
srcs = ["JwtService.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
deps = [
"@maven//:com_nimbusds_nimbus_jose_jwt",
],
)
scala_library(
name = "oauth_config",
srcs = ["OAuthConfig.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
)
scala_library(
name = "oauth_service",
srcs = ["OAuthService.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
deps = [
":oauth_config",
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
"@maven//:org_json4s_json4s_ast_3",
"@maven//:org_json4s_json4s_core_3",
"@maven//:org_json4s_json4s_native_3",
],
)
scala_library(
name = "user_service",
srcs = ["UserService.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:user_scala_proto",
"//src/main/scala/net/eagle0/eagle/service/persistence:persister",
],
)
@@ -1,182 +0,0 @@
package net.eagle0.eagle.auth
import java.security.MessageDigest
import java.time.Instant
import java.util.{Date, UUID}
import scala.jdk.CollectionConverters.*
import scala.util.Try
import com.nimbusds.jose.{JWSAlgorithm, JWSHeader}
import com.nimbusds.jose.crypto.{RSASSASigner, RSASSAVerifier}
import com.nimbusds.jose.jwk.gen.RSAKeyGenerator
import com.nimbusds.jose.jwk.RSAKey
import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT}
/** JWT claims extracted from a validated token */
case class JwtClaims(
userId: String,
displayName: String,
isAdmin: Boolean,
issuedAt: Instant,
expiresAt: Instant
)
/** Refresh token with metadata */
case class RefreshTokenInfo(
tokenId: String,
userId: String,
tokenHash: String,
expiresAt: Instant
)
/** Service for creating and validating JWT tokens */
trait JwtService {
/** Create an access token for a user */
def createAccessToken(userId: String, displayName: String, isAdmin: Boolean): String
/** Create a refresh token (returns token string and info for storage) */
def createRefreshToken(userId: String): (String, RefreshTokenInfo)
/** Validate an access token and extract claims */
def validateAccessToken(token: String): Option[JwtClaims]
/** Hash a refresh token for storage */
def hashRefreshToken(token: String): String
}
class JwtServiceImpl(rsaKey: RSAKey) extends JwtService {
private val accessTokenDuration = java.time.Duration.ofDays(7)
private val refreshTokenDuration = java.time.Duration.ofDays(30)
private val issuer = "eagle0"
private val signer = new RSASSASigner(rsaKey)
private val verifier = new RSASSAVerifier(rsaKey.toPublicJWK)
override def createAccessToken(
userId: String,
displayName: String,
isAdmin: Boolean
): String = {
val now = Instant.now()
val expiresAt = now.plus(accessTokenDuration)
val claimsBuilder = new JWTClaimsSet.Builder()
.subject(userId)
.issuer(issuer)
.issueTime(Date.from(now))
.expirationTime(Date.from(expiresAt))
.claim("name", displayName)
// Only include admin claim if true to keep tokens smaller
if isAdmin then {
claimsBuilder.claim("admin", true)
}
val signedJWT = new SignedJWT(
new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID).build(),
claimsBuilder.build()
)
signedJWT.sign(signer)
signedJWT.serialize()
}
override def createRefreshToken(userId: String): (String, RefreshTokenInfo) = {
val tokenId = UUID.randomUUID().toString
val tokenSecret = UUID.randomUUID().toString
val token = s"$tokenId.$tokenSecret"
val tokenHash = hashRefreshToken(token)
val expiresAt = Instant.now().plus(refreshTokenDuration)
val info = RefreshTokenInfo(
tokenId = tokenId,
userId = userId,
tokenHash = tokenHash,
expiresAt = expiresAt
)
(token, info)
}
override def validateAccessToken(token: String): Option[JwtClaims] =
Try {
val signedJWT = SignedJWT.parse(token)
// Verify signature
if !signedJWT.verify(verifier) then {
return None
}
val claims = signedJWT.getJWTClaimsSet
// Check issuer
if claims.getIssuer != issuer then {
return None
}
// Check expiration
val expirationTime = claims.getExpirationTime
if expirationTime == null || expirationTime.before(new Date()) then {
return None
}
val userId = claims.getSubject
val displayName = Option(claims.getStringClaim("name")).getOrElse("")
val isAdmin = Option(claims.getBooleanClaim("admin")).map(_.booleanValue()).getOrElse(false)
val issuedAt = claims.getIssueTime.toInstant
val expiresAt = expirationTime.toInstant
Some(
JwtClaims(
userId = userId,
displayName = displayName,
isAdmin = isAdmin,
issuedAt = issuedAt,
expiresAt = expiresAt
)
)
}.toOption.flatten
override def hashRefreshToken(token: String): String = {
val digest = MessageDigest.getInstance("SHA-256")
val hashBytes = digest.digest(token.getBytes("UTF-8"))
hashBytes.map("%02x".format(_)).mkString
}
}
object JwtServiceImpl {
/** Generate a new RSA key pair for JWT signing */
def generateKey(): RSAKey =
new RSAKeyGenerator(2048)
.keyID(UUID.randomUUID().toString)
.generate()
/** Load RSA key from JSON string (JWK format) */
def loadKey(json: String): RSAKey =
RSAKey.parse(json)
/** Create a JwtService with a new generated key (for testing) */
def withGeneratedKey(): JwtServiceImpl =
new JwtServiceImpl(generateKey())
/** Create a JwtService from a JWK JSON string */
def fromKeyJson(json: String): JwtServiceImpl =
new JwtServiceImpl(loadKey(json))
/**
* Create a JwtService from environment variables.
*
* Looks for JWT_PRIVATE_KEY environment variable containing RSA key in JWK format. If not found, returns None (auth
* will fall back to Basic Auth only).
*/
def fromEnvironment(): Option[JwtServiceImpl] =
sys.env.get("JWT_PRIVATE_KEY").flatMap { keyJson =>
Try {
fromKeyJson(keyJson)
}.toOption
}
}
@@ -1,41 +0,0 @@
package net.eagle0.eagle.auth
/** Configuration for an OAuth provider */
case class OAuthProviderConfig(
clientId: String,
clientSecret: String,
authorizationEndpoint: String,
tokenEndpoint: String,
userInfoEndpoint: String,
scopes: Seq[String]
)
/** OAuth configuration loaded from environment variables */
object OAuthConfig {
def discord: OAuthProviderConfig = OAuthProviderConfig(
clientId = sys.env.getOrElse("DISCORD_CLIENT_ID", ""),
clientSecret = sys.env.getOrElse("DISCORD_CLIENT_SECRET", ""),
authorizationEndpoint = "https://discord.com/api/oauth2/authorize",
tokenEndpoint = "https://discord.com/api/oauth2/token",
userInfoEndpoint = "https://discord.com/api/users/@me",
scopes = Seq("identify", "email")
)
def google: OAuthProviderConfig = OAuthProviderConfig(
clientId = sys.env.getOrElse("GOOGLE_CLIENT_ID", ""),
clientSecret = sys.env.getOrElse("GOOGLE_CLIENT_SECRET", ""),
authorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth",
tokenEndpoint = "https://oauth2.googleapis.com/token",
userInfoEndpoint = "https://www.googleapis.com/oauth2/v2/userinfo",
scopes = Seq("openid", "email", "profile")
)
/** Check if Discord OAuth is configured */
def isDiscordConfigured: Boolean =
discord.clientId.nonEmpty && discord.clientSecret.nonEmpty
/** Check if Google OAuth is configured */
def isGoogleConfigured: Boolean =
google.clientId.nonEmpty && google.clientSecret.nonEmpty
}
@@ -1,213 +0,0 @@
// Copyright 2025 Dan Crosby
package net.eagle0.eagle.auth
import java.net.{URI, URLEncoder}
import java.net.http.{HttpClient, HttpRequest, HttpResponse}
import java.nio.charset.StandardCharsets
import java.time.Duration
import java.util.UUID
import scala.collection.concurrent.TrieMap
import scala.util.Try
import net.eagle0.eagle.api.auth.auth.OAuthProvider
import org.json4s.{DefaultFormats, JObject, JString}
import org.json4s.jvalue2extractable
import org.json4s.jvalue2monadic
import org.json4s.native.Json
/** User info from OAuth provider */
case class ProviderUserInfo(
id: String,
email: String,
avatarUrl: String,
username: String
)
/** Service for OAuth code exchange */
trait OAuthService {
/** Generate OAuth authorization URL */
def getAuthUrl(provider: OAuthProvider, redirectUri: String): (String, String) // (url, state)
/** Exchange authorization code for user info */
def exchangeCode(
provider: OAuthProvider,
code: String,
state: String,
redirectUri: String
): Either[String, ProviderUserInfo]
}
class OAuthServiceImpl extends OAuthService {
private implicit val formats: DefaultFormats.type = DefaultFormats
private val json = new Json(formats)
private val httpClient = HttpClient
.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build()
// State parameter storage with timestamp (for CSRF protection)
private val stateStore = TrieMap[String, Long]()
// State expiration (10 minutes)
private val stateExpirationMs = 600000L
override def getAuthUrl(provider: OAuthProvider, redirectUri: String): (String, String) = {
val config = getConfig(provider)
val state = UUID.randomUUID().toString
stateStore.put(state, System.currentTimeMillis())
// Clean old states
val cutoff = System.currentTimeMillis() - stateExpirationMs
stateStore.filterInPlace((_, timestamp) => timestamp > cutoff)
val params = Map(
"client_id" -> config.clientId,
"redirect_uri" -> redirectUri,
"response_type" -> "code",
"scope" -> config.scopes.mkString(" "),
"state" -> state
)
val queryString = params.map {
case (k, v) =>
s"$k=${URLEncoder.encode(v, StandardCharsets.UTF_8)}"
}
.mkString("&")
val url = s"${config.authorizationEndpoint}?$queryString"
(url, state)
}
override def exchangeCode(
provider: OAuthProvider,
code: String,
state: String,
redirectUri: String
): Either[String, ProviderUserInfo] = {
// Verify state
stateStore.remove(state) match {
case Some(timestamp) if System.currentTimeMillis() - timestamp < stateExpirationMs =>
// Valid state
case _ =>
return Left("Invalid or expired state parameter")
}
val config = getConfig(provider)
// Exchange code for access token
val tokenResult = exchangeCodeForToken(config, code, redirectUri)
tokenResult match {
case Left(error) => Left(error)
case Right(accessToken) =>
// Fetch user info
fetchUserInfo(provider, config, accessToken)
}
}
private def getConfig(provider: OAuthProvider): OAuthProviderConfig =
provider match {
case OAuthProvider.OAUTH_PROVIDER_DISCORD => OAuthConfig.discord
case OAuthProvider.OAUTH_PROVIDER_GOOGLE => OAuthConfig.google
case _ => throw new IllegalArgumentException(s"Unsupported provider: $provider")
}
private def exchangeCodeForToken(
config: OAuthProviderConfig,
code: String,
redirectUri: String
): Either[String, String] =
Try {
val formData = Map(
"client_id" -> config.clientId,
"client_secret" -> config.clientSecret,
"grant_type" -> "authorization_code",
"code" -> code,
"redirect_uri" -> redirectUri
)
val formBody = formData.map {
case (k, v) =>
s"${URLEncoder.encode(k, StandardCharsets.UTF_8)}=${URLEncoder.encode(v, StandardCharsets.UTF_8)}"
}
.mkString("&")
val request = HttpRequest
.newBuilder()
.uri(URI.create(config.tokenEndpoint))
.timeout(Duration.ofSeconds(10))
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(formBody))
.build()
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
val body = response.body()
if response.statusCode() < 200 || response.statusCode() >= 300 then {
return Left(s"Token exchange failed: ${response.statusCode()} - $body")
}
val parsed = json.parse(body)
val accessToken = (parsed \ "access_token").extract[String]
Right(accessToken)
}.toEither.left.map(e => s"Token exchange error: ${e.getMessage}").flatten
private def fetchUserInfo(
provider: OAuthProvider,
config: OAuthProviderConfig,
accessToken: String
): Either[String, ProviderUserInfo] =
Try {
val request = HttpRequest
.newBuilder()
.uri(URI.create(config.userInfoEndpoint))
.timeout(Duration.ofSeconds(10))
.header("Authorization", s"Bearer $accessToken")
.header("Accept", "application/json")
.GET()
.build()
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
val body = response.body()
if response.statusCode() < 200 || response.statusCode() >= 300 then {
return Left(s"User info fetch failed: ${response.statusCode()} - $body")
}
val parsed = json.parse(body)
parseUserInfo(provider, parsed)
}.toEither.left.map(e => s"User info fetch error: ${e.getMessage}").flatten
private def parseUserInfo(
provider: OAuthProvider,
data: org.json4s.JValue
): Either[String, ProviderUserInfo] =
Try {
provider match {
case OAuthProvider.OAUTH_PROVIDER_DISCORD =>
val id = (data \ "id").extract[String]
val username = (data \ "username").extract[String]
val email = (data \ "email").extractOpt[String].getOrElse("")
val avatarHash = (data \ "avatar").extractOpt[String]
val avatarUrl = avatarHash
.map(hash => s"https://cdn.discordapp.com/avatars/$id/$hash.png")
.getOrElse("")
ProviderUserInfo(id, email, avatarUrl, username)
case OAuthProvider.OAUTH_PROVIDER_GOOGLE =>
val id = (data \ "id").extract[String]
val email = (data \ "email").extractOpt[String].getOrElse("")
val name = (data \ "name").extractOpt[String].getOrElse("")
val avatarUrl = (data \ "picture").extractOpt[String].getOrElse("")
ProviderUserInfo(id, email, avatarUrl, name)
case _ =>
throw new IllegalArgumentException(s"Unsupported provider: $provider")
}
}.toEither.left.map(e => s"Failed to parse user info: ${e.getMessage}")
}
@@ -1,176 +0,0 @@
// Copyright 2025 Dan Crosby
package net.eagle0.eagle.auth
import java.time.Instant
import java.util.UUID
import scala.util.{Success, Using}
import com.google.protobuf.timestamp.Timestamp
import net.eagle0.eagle.internal.user.user.{OAuthIdentity, User, UserDatabase}
import net.eagle0.eagle.service.persistence.Persister
/** Service for managing user records */
trait UserService {
/** Find or create a user from OAuth provider info */
def findOrCreateUser(
provider: String,
providerUserId: String,
email: String,
avatarUrl: String
): User
/** Find user by internal user ID */
def findByUserId(userId: String): Option[User]
/** Find user by display name (case-insensitive) */
def findByDisplayName(displayName: String): Option[User]
/** Set or update display name */
def setDisplayName(userId: String, displayName: String): Either[String, User]
/** Check if display name is available */
def isDisplayNameAvailable(displayName: String): Boolean
}
class UserServiceImpl(persister: Persister) extends UserService {
private val usersPath = "auth/users.pb"
// Thread-safe access to user database
private var database: UserDatabase = loadDatabase()
private val lock = new Object
private def loadDatabase(): UserDatabase =
persister.retrieveAsStream(usersPath) match {
case Success(stream) =>
Using(stream) { s =>
UserDatabase.parseFrom(s)
}.getOrElse(UserDatabase.defaultInstance)
case _ => UserDatabase.defaultInstance
}
private def saveDatabase(): Unit = {
persister.save(usersPath, database.toByteArray)
()
}
private def nowTimestamp: Timestamp = {
val now = Instant.now()
Timestamp(now.getEpochSecond, now.getNano)
}
override def findOrCreateUser(
provider: String,
providerUserId: String,
email: String,
avatarUrl: String
): User = lock.synchronized {
val oauthKey = s"$provider:$providerUserId"
database.oauthIndex.get(oauthKey) match {
case Some(existingUserId) =>
// Update last login, avatar
val user = findByUserIdInternal(existingUserId).get
val updated = user.copy(
lastLoginAt = Some(nowTimestamp),
avatarUrl = avatarUrl
)
updateUser(updated)
updated
case None =>
// Create new user
val userId = UUID.randomUUID().toString
val now = nowTimestamp
val identity = OAuthIdentity(
provider = provider,
providerUserId = providerUserId,
email = email,
linkedAt = Some(now)
)
val user = User(
userId = userId,
displayName = "",
displayNameLower = "",
oauthIdentities = Seq(identity),
createdAt = Some(now),
lastLoginAt = Some(now),
avatarUrl = avatarUrl,
isAdmin = false
)
database = database.copy(
users = database.users :+ user,
oauthIndex = database.oauthIndex + (oauthKey -> userId)
)
saveDatabase()
user
}
}
override def findByUserId(userId: String): Option[User] = lock.synchronized {
findByUserIdInternal(userId)
}
private def findByUserIdInternal(userId: String): Option[User] =
database.users.find(_.userId == userId)
override def findByDisplayName(displayName: String): Option[User] = lock.synchronized {
val lowerName = displayName.toLowerCase
database.displayNameIndex.get(lowerName).flatMap(findByUserIdInternal)
}
override def setDisplayName(userId: String, displayName: String): Either[String, User] =
lock.synchronized {
// Validate: 3-20 chars, alphanumeric + underscore
val validPattern = "^[a-zA-Z0-9_]{3,20}$".r
if !validPattern.matches(displayName) then {
return Left("Display name must be 3-20 characters, alphanumeric and underscore only")
}
val lowerName = displayName.toLowerCase
// Check uniqueness
database.displayNameIndex.get(lowerName) match {
case Some(existingId) if existingId != userId =>
Left("Display name already taken")
case _ =>
findByUserIdInternal(userId) match {
case Some(user) =>
// Remove old name from index if exists
val newIndex = user.displayNameLower match {
case "" => database.displayNameIndex
case old => database.displayNameIndex - old
}
val updated = user.copy(
displayName = displayName,
displayNameLower = lowerName
)
database = database.copy(
users = database.users.filterNot(_.userId == userId) :+ updated,
displayNameIndex = newIndex + (lowerName -> userId)
)
saveDatabase()
Right(updated)
case None =>
Left("User not found")
}
}
}
override def isDisplayNameAvailable(displayName: String): Boolean = lock.synchronized {
!database.displayNameIndex.contains(displayName.toLowerCase)
}
private def updateUser(user: User): Unit = {
database = database.copy(
users = database.users.filterNot(_.userId == user.userId) :+ user
)
saveDatabase()
}
}
@@ -309,16 +309,14 @@ case class EngineImpl(
initialState = this.currentState,
actionResultApplier = ActionResultApplierImpl(Some(ScalaRuntimeValidator)),
functionalRandom = SeededRandom(this.currentState.randomSeed)
).withTCommandAndLastCommand(
commandGen = gs =>
commandFactory.makeTCommand(
actingFactionId = factionId,
gameState = gs,
availableCommand = availableCommand,
selectedCommand = selectedCommand
),
selectedCommand = selectedCommand
).withProtolessSequentialResultsAction(gs => HeroBackstoryUpdateActionGenerator(gs))
).withTCommand { gs =>
commandFactory.makeTCommand(
actingFactionId = factionId,
gameState = gs,
availableCommand = availableCommand,
selectedCommand = selectedCommand
)
}.withProtolessSequentialResultsAction(gs => HeroBackstoryUpdateActionGenerator(gs))
// Validate that the first result has an acting faction set (required for player commands)
val firstResult = sequencer.actionResults.newValue.headOption
@@ -9,6 +9,8 @@ case class ActionResultWithResultingState(
)
trait ActionResultApplier {
def xpForStatBump(stat: Int): Int
def applyActionResults(
startingState: GameState,
results: Iterable[ActionResultT]
@@ -56,6 +56,8 @@ class ActionResultApplierImpl(validator: Option[ScalaValidator]) extends ActionR
def validate(province: ProvinceT, roundPhase: RoundPhase): ProvinceT =
validator.fold(province)(_.validate(province, roundPhase))
override def xpForStatBump(stat: Int): Int = GameStateExtensions.xpForStatBump(stat)
override def applyActionResults(
startingState: GameState,
results: Iterable[ActionResultT]
@@ -4,6 +4,8 @@ import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.actions.impl.common.ActionWithResultingState
trait ActionResultProtoApplier {
def xpForStatBump(stat: Int): Int
def applyActionResults(
startingState: GameState,
results: Iterable[ActionResult]
@@ -47,6 +47,7 @@ import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.internal.run_status.RunStatus
import net.eagle0.eagle.internal.shardok_battle.ShardokBattle
import net.eagle0.eagle.library.actions.impl.common.ActionWithResultingState
import net.eagle0.eagle.library.settings.{ExtraXpForStatBumpOver100, XpForStatBump}
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.library.util.validations.Validator
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
@@ -56,6 +57,10 @@ import net.eagle0.eagle.library.EagleInternalException
class ActionResultProtoApplierImpl(validator: Validator) extends ActionResultProtoApplier {
import validator.validate
override def xpForStatBump(stat: Int): Int =
if stat <= 99 then XpForStatBump.intValue
else XpForStatBump.intValue + ExtraXpForStatBumpOver100.intValue * (stat - 99)
private val trustMax: Int = 100
implicit class _GS(gameState: GameState) {
@@ -9,6 +9,8 @@ case class ActionResultTWithResultingState(
)
trait ActionResultTApplier {
def xpForStatBump(stat: Int): Int
def applyActionResults(
startingState: GameState,
results: Iterable[ActionResultT]
@@ -6,7 +6,6 @@ import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
object ActionResultTApplierImpl {
/** Creates an ActionResultTApplierImpl with no validation (for tests) */
def apply(): ActionResultTApplierImpl =
new ActionResultTApplierImpl(new ActionResultApplierImpl(None))
@@ -19,6 +18,8 @@ object ActionResultTApplierImpl {
}
class ActionResultTApplierImpl(baseApplier: ActionResultApplier) extends ActionResultTApplier {
override def xpForStatBump(stat: Int): Int = baseApplier.xpForStatBump(stat)
override def applyActionResults(
startingState: GameState,
results: Iterable[ActionResultT]
@@ -242,6 +242,8 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/settings:extra_xp_for_stat_bump_over100",
"//src/main/scala/net/eagle0/eagle/library/settings:xp_for_stat_bump",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
"//src/main/scala/net/eagle0/eagle/library/util:province_event_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
@@ -108,7 +108,6 @@ object GameStateFactionExtensions {
)
val updatedFaction = existingFaction.copy(
name = cf.newName.getOrElse(existingFaction.name),
factionHeadId = cf.newFactionHeadHeroId.getOrElse(existingFaction.factionHeadId),
leaderIds = existingFaction.leaderIds.diff(cf.removedLeaderHeroIds) ++ cf.newLeaderHeroIds,
prestigeModifiers =
@@ -7,14 +7,9 @@ import net.eagle0.eagle.internal.army.Attacking
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.settings.{MaxCombatUnitCountPerSide, MinVigorForDefend}
import net.eagle0.eagle.library.util.battalion_suitability.BattalionSuitability
import net.eagle0.eagle.library.util.{BattalionSuitability, LegacyBattalionUtils}
import net.eagle0.eagle.library.util.command_choice_helpers.CombatUnitSelector
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.LegacyBattalionUtils
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.proto_converters.SuitableBattalionsConverter
object AvailableDefendCommandsFactory extends AvailableCommandsFactoryForType {
private def sortedFleeProvinces(
@@ -76,9 +71,9 @@ object AvailableDefendCommandsFactory extends AvailableCommandsFactoryForType {
val recommendedUnits = CombatUnitSelector.selectedCombatBattalions(
desiredCount = availableHeroes.size.min(MaxCombatUnitCountPerSide.intValue),
heroes = availableHeroes.map(HeroConverter.fromProto).toVector,
battalions = availableBattalions.map(BattalionConverter.fromProto).toVector,
battalionTypes = gameState.battalionTypes.map(BattalionTypeConverter.fromProto).toVector
heroes = availableHeroes.toVector,
battalions = availableBattalions.toVector,
battalionTypes = gameState.battalionTypes.toVector
)
val hostileUnits = hostileArmies
.flatMap(_.armies)
@@ -91,12 +86,10 @@ object AvailableDefendCommandsFactory extends AvailableCommandsFactoryForType {
.toVector
.map(_.id),
actingProvinceId = province.id,
suitableBattalionsForHeroes = SuitableBattalionsConverter.toProtoMap(
BattalionSuitability.suitableBattalionsForHeroes(
hs = availableHeroes.map(HeroConverter.fromProto).toVector,
bs = availableBattalions.map(BattalionConverter.fromProto).toVector,
bts = gameState.battalionTypes.map(BattalionTypeConverter.fromProto).toVector
)
suitableBattalionsForHeroes = BattalionSuitability.suitableBattalionsForHeroes(
hs = availableHeroes.toVector,
bs = availableBattalions.toVector,
bts = gameState.battalionTypes.toVector
),
availableBattalions = availableBattalionsWithCosts,
availableFleeProvinceIds = sortedFleeProvinces(from = province, gs = gameState),
@@ -11,15 +11,10 @@ import net.eagle0.eagle.api.command.util.battalion_with_food_cost.BattalionWithF
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.settings.{MaxCombatUnitCountPerSide, MinVigorForMarch}
import net.eagle0.eagle.library.util.{LegacyBattalionUtils, ShardokMapInfo}
import net.eagle0.eagle.library.util.battalion_suitability.BattalionSuitability
import net.eagle0.eagle.library.util.{BattalionSuitability, LegacyBattalionUtils, ShardokMapInfo}
import net.eagle0.eagle.library.util.command_choice_helpers.{CombatUnitSelector, MarchSuppliesHelpers}
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
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.proto_converters.SuitableBattalionsConverter
object AvailableMarchCommandFactory extends AvailableCommandsFactoryForType {
@@ -72,14 +67,14 @@ object AvailableMarchCommandFactory extends AvailableCommandsFactoryForType {
Option.when(recommendedSendCount > 0) {
val units = CombatUnitSelector.selectedCombatBattalions(
desiredCount = recommendedSendCount,
heroes = availableHeroes.map(HeroConverter.fromProto).toVector,
battalions = availableBattalions.map(BattalionConverter.fromProto).toVector,
battalionTypes = gameState.battalionTypes.map(BattalionTypeConverter.fromProto).toVector
heroes = availableHeroes.toVector,
battalions = availableBattalions.toVector,
battalionTypes = gameState.battalionTypes.toVector
)
RecommendedBattleConfig(
units = units,
food = MarchSuppliesHelpers.foodToTakeOnAttack(
units.flatMap(_.battalionId).map(gameState.battalions).map(BattalionConverter.fromProto),
units.flatMap(_.battalionId).map(gameState.battalions),
originProvince.food
),
gold = MarchSuppliesHelpers.goldToTakeOnAttack(
@@ -102,13 +97,12 @@ object AvailableMarchCommandFactory extends AvailableCommandsFactoryForType {
)
}.toVector,
availableDestinationProvinces = availableDestinations,
suitableBattalionsForHeroes = SuitableBattalionsConverter.toProtoMap(
BattalionSuitability.suitableBattalionsForHeroes(
hs = availableHeroes.map(HeroConverter.fromProto).toVector,
bs = availableBattalions.map(BattalionConverter.fromProto).toVector,
bts = gameState.battalionTypes.map(BattalionTypeConverter.fromProto).toVector
)
),
suitableBattalionsForHeroes = BattalionSuitability
.suitableBattalionsForHeroes(
hs = availableHeroes.toVector,
bs = availableBattalions.toVector,
bts = gameState.battalionTypes.toVector
),
originProvinceId = originProvince.id,
goldAvailable = originProvince.gold,
foodAvailable = originProvince.food,
@@ -187,16 +187,10 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:max_combat_unit_count_per_side",
"//src/main/scala/net/eagle0/eagle/library/settings:min_vigor_for_defend",
"//src/main/scala/net/eagle0/eagle/library/util:battalion_suitability",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_battalion_utils",
"//src/main/scala/net/eagle0/eagle/library/util/battalion_suitability",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:combat_unit_selector",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_type_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:suitable_battalions_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
],
)
@@ -452,19 +446,13 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:max_combat_unit_count_per_side",
"//src/main/scala/net/eagle0/eagle/library/settings:min_vigor_for_march",
"//src/main/scala/net/eagle0/eagle/library/util:battalion_suitability",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_battalion_utils",
"//src/main/scala/net/eagle0/eagle/library/util:shardok_map_info",
"//src/main/scala/net/eagle0/eagle/library/util/battalion_suitability",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:combat_unit_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:march_supplies_helpers",
"//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",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_type_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:suitable_battalions_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
],
)
@@ -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"],
@@ -29,7 +42,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_battalion_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_faction_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:changed_hero_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:notification_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:faction_destroyed_result_type",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:faction_leader_removed_result_type",
@@ -104,7 +116,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/settings:min_support_for_taxes",
"//src/main/scala/net/eagle0/eagle/library/settings:portion_of_capacity_for_upgrade_battalion_quest",
"//src/main/scala/net/eagle0/eagle/library/util/battalion_type_finder:legacy_battalion_type_finder",
"//src/main/scala/net/eagle0/eagle/library/util:battalion_type_finder",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/library/util/quest_fulfillment:quest_fulfillment_utils",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
@@ -137,16 +149,14 @@ scala_library(
exports = [
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:action_result_notification_details_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/common:action_result_type_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_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/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:game_history",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request/chronicle_event",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/date",
"//src/main/scala/net/eagle0/eagle/model/state/date",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -779,6 +789,7 @@ scala_library(
],
deps = [
":hero_backstory_update_action",
"//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/actions/impl/common:protoless_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
@@ -875,6 +886,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request/chronicle_event",
"//src/main/scala/net/eagle0/eagle/model/action_result/types:new_round_action_result_type",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/llm_request/chronicle_event",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/chronicle_entry",
"//src/main/scala/net/eagle0/eagle/model/state/date",
@@ -6,10 +6,8 @@ import net.eagle0.eagle.library.actions.impl.common.ProtolessRandomSequentialRes
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.ransom_validity.RansomValidity
import net.eagle0.eagle.library.util.ReturningHeroes
import net.eagle0.eagle.model.action_result.{NotificationDetails, NotificationT}
import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedProvinceC
import net.eagle0.eagle.model.action_result.concrete.{ActionResultC, ChangedFactionC, ChangedHeroC, NotificationC}
import net.eagle0.eagle.model.action_result.generated_text_request.LlmRequestT.NewFactionHeadMessage
import net.eagle0.eagle.model.action_result.concrete.{ActionResultC, ChangedFactionC, ChangedHeroC}
import net.eagle0.eagle.model.action_result.types.{
FactionDestroyedResultType,
FactionLeaderRemovedResultType,
@@ -210,47 +208,24 @@ case class CheckForFactionChangesAction(
revisedFactions match {
case Nil => None
case fs =>
val changedFactionData = fs.map { revisedFaction =>
val originalFaction = factions.find(_.id == revisedFaction.id).get
val newHeadHero = heroes.find(_.id == revisedFaction.factionHeadId)
val newFactionName = newHeadHero.flatMap(_.ledFactionName)
val llmRequestId = s"new_faction_head_${revisedFaction.id}_${revisedFaction.factionHeadId}"
(
ChangedFactionC(
factionId = revisedFaction.id,
removedLeaderHeroIds = originalFaction.leaderIds.filter(killedHeroIds.contains),
newFactionHeadHeroId = Some(revisedFaction.factionHeadId),
newName = newFactionName
),
NewFactionHeadMessage(
requestId = llmRequestId,
eagleGameId = gameId,
recipientFactionIds = Vector.empty,
alwaysGenerate = false,
newHeadHeroId = revisedFaction.factionHeadId,
factionId = revisedFaction.id,
previousFactionName = originalFaction.name,
newFactionName = newFactionName,
previousHeadHeroId = originalFaction.factionHeadId
),
NotificationC(
details = NotificationDetails.NewFactionHead(
newHeadHeroId = revisedFaction.factionHeadId,
factionId = revisedFaction.id,
previousHeadHeroId = originalFaction.factionHeadId
),
affectedHeroIds = Vector(revisedFaction.factionHeadId, originalFaction.factionHeadId),
llm = NotificationT.Llm.Id(llmRequestId),
deferred = true
)
)
}.toVector
Some(
ActionResultC(
actionResultType = FactionLeaderRemovedResultType,
changedFactions = changedFactionData.map(_._1),
newGeneratedTextRequests = changedFactionData.map(_._2),
newNotifications = changedFactionData.map(_._3)
changedFactions = fs
.map(revisedFaction =>
ChangedFactionC(
factionId = revisedFaction.id,
removedLeaderHeroIds = factions
.find(
_.id == revisedFaction.id
)
.get
.leaderIds
.filter(killedHeroIds.contains),
newFactionHeadHeroId = Some(revisedFaction.factionHeadId)
)
)
.toVector
)
)
}
@@ -4,9 +4,9 @@ import net.eagle0.eagle.{BattalionId, FactionId, GameId, HeroId, ProvinceId, Rou
import net.eagle0.eagle.common.battalion_type.BattalionType
import net.eagle0.eagle.library.actions.impl.common.ProtolessSequentialResultsAction
import net.eagle0.eagle.library.settings.{MinSupportForTaxes, PortionOfCapacityForUpgradeBattalionQuest}
import net.eagle0.eagle.library.util.battalion_type_finder.LegacyBattalionTypeFinder
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.quest_fulfillment.QuestFulfillmentUtils
import net.eagle0.eagle.library.util.BattalionTypeFinder
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.state.battalion.BattalionT
import net.eagle0.eagle.model.state.date.Date
@@ -49,7 +49,7 @@ case class CheckForFulfilledQuestsAction(
heroBackstoryTextIdLookup: HeroId => String
) extends ProtolessSequentialResultsAction {
private def battalionType(id: BattalionTypeId): BattalionType =
LegacyBattalionTypeFinder.battalionType(id, battalionTypes)
BattalionTypeFinder.battalionType(id, battalionTypes)
private def battalionWithId(id: BattalionId): Option[BattalionT] =
battalions.find(_.id == id)
@@ -28,14 +28,47 @@ import net.eagle0.eagle.common.action_result_notification_details.{
}
import net.eagle0.eagle.common.action_result_notification_details.ShatteredArmyDetails.Reason.{
SHATTERED_ARMY_REASON_BLIZZARD,
SHATTERED_ARMY_REASON_WITHDRAW
SHATTERED_ARMY_REASON_UNKNOWN_UNSPECIFIED,
SHATTERED_ARMY_REASON_WITHDRAW,
Unrecognized
}
import net.eagle0.eagle.common.action_result_type.ActionResultType.FACTION_DESTROYED
import net.eagle0.eagle.internal.action_result.ActionResult
import net.eagle0.eagle.internal.event_for_chronicle.{
AllianceAcceptedEvent,
BreakAllianceAmbassadorImprisonedEvent,
BreakAllianceEvent,
CapturedHeroExecutedEvent,
CapturedHeroExiledEvent,
CapturedHeroImprisonedEvent,
EventForChronicle,
EventForChronicleDetails,
FactionDestroyedEvent,
HeroDepartedEvent,
InvitationAcceptedEvent,
InvitationAmbassadorImprisonedEvent,
OutlawApprehendedEvent,
PrisonerExecutedEvent,
PrisonersExchangedEvent,
ProvinceConqueredEvent,
ProvinceExpansionEvent,
ProvinceHeldEvent,
RansomPaidEvent,
RansomRejectedEvent,
ShatteredArmyEvent,
SuppressBeastsFailedEvent,
SwearBrotherhoodEvent,
TruceAcceptedEvent,
TruceAmbassadorImprisonedEvent,
VassalExiledEvent
}
import net.eagle0.eagle.internal.event_for_chronicle.ShatteredArmyEvent.Reason.{
SHATTERED_ARMY_EVENT_REASON_BLIZZARD,
SHATTERED_ARMY_EVENT_REASON_UNKNOWN_UNSPECIFIED,
SHATTERED_ARMY_EVENT_REASON_WITHDRAW
}
import net.eagle0.eagle.library.actions.impl.common.ActionWithResultingState
import net.eagle0.eagle.library.GameHistory
import net.eagle0.eagle.model.action_result.generated_text_request.chronicle_event.*
import net.eagle0.eagle.model.proto_converters.date.DateConverter
import net.eagle0.eagle.model.state.date.Date
object ChronicleEventGenerator {
@@ -46,341 +79,325 @@ object ChronicleEventGenerator {
private def notificationEntries(
awrs: ActionWithResultingState
): Vector[ChronicleEvent] = {
val date = DateConverter.fromProto(awrs.gameState.currentDate)
relevantNotifications(awrs.actionResult)
.map(_.details)
.collect {
case AllianceAcceptedDetails(
offeringFid,
targetFid,
ambassadorHid,
_ /* unknownFieldSet */
) =>
AllianceAcceptedChronicleEvent(
date = date,
offeringFactionId = offeringFid,
targetFactionId = targetFid,
ambassadorHeroId = ambassadorHid
)
): Vector[EventForChronicle] = {
val detailList: Vector[EventForChronicleDetails] =
relevantNotifications(awrs.actionResult)
.map(_.details)
.collect {
case AllianceAcceptedDetails(
offeringFid,
targetFid,
ambassadorHid,
_ /* unknownFieldSet */
) =>
AllianceAcceptedEvent(
offeringFactionId = offeringFid,
targetFactionId = targetFid,
ambassadorHeroId = ambassadorHid
)
case CapturedHeroExiledDetails(
capturedFromFactionId,
provinceId,
exiledHeroId,
exilingFactionId,
_ /* unknownFieldSet */
) =>
CapturedHeroExiledChronicleEvent(
date = date,
capturedFromFactionId = capturedFromFactionId,
provinceId = provinceId,
exiledHeroId = exiledHeroId,
exilingFactionId = exilingFactionId
)
case CapturedHeroExiledDetails(
capturedFromFactionId,
provinceId,
exiledHeroId,
exilingFactionId,
_ /* unknownFieldSet */
) =>
CapturedHeroExiledEvent(
capturedFromFactionId = capturedFromFactionId,
provinceId = provinceId,
exiledHeroId = exiledHeroId,
exilingFactionId = exilingFactionId
)
case CapturedHeroExecutedDetails(
capturedFromFactionId,
provinceId,
executedHeroId,
executingFactionId,
_ /* unknownFieldSet */
) =>
CapturedHeroExecutedChronicleEvent(
date = date,
capturedFromFactionId = capturedFromFactionId,
provinceId = provinceId,
executedHeroId = executedHeroId,
executingFactionId = executingFactionId
)
case CapturedHeroExecutedDetails(
capturedFromFactionId,
provinceId,
executedHeroId,
executingFactionId,
_ /* unknownFieldSet */
) =>
CapturedHeroExecutedEvent(
capturedFromFactionId = capturedFromFactionId,
provinceId = provinceId,
executedHeroId = executedHeroId,
executingFactionId = executingFactionId
)
case CapturedHeroImprisonedDetails(
capturedFromFactionId,
provinceId,
imprisonedHeroId,
imprisoningFactionId,
_ /* unknownFieldSet */
) =>
CapturedHeroImprisonedChronicleEvent(
date = date,
capturedFromFactionId = capturedFromFactionId,
provinceId = provinceId,
imprisonedHeroId = imprisonedHeroId,
imprisoningFactionId = imprisoningFactionId
)
case CapturedHeroImprisonedDetails(
capturedFromFactionId,
provinceId,
imprisonedHeroId,
imprisoningFactionId,
_ /* unknownFieldSet */
) =>
CapturedHeroImprisonedEvent(
capturedFromFactionId = capturedFromFactionId,
provinceId = provinceId,
imprisonedHeroId = imprisonedHeroId,
imprisoningFactionId = imprisoningFactionId
)
case HeroDepartureDetails(
departingHeroId,
fromFactionId,
provinceId,
_ /* unknownFieldSet */
) =>
HeroDepartedChronicleEvent(
date = date,
departingHeroId = departingHeroId,
fromFactionId = fromFactionId,
provinceId = provinceId
)
case HeroDepartureDetails(
departingHeroId,
fromFactionId,
provinceId,
_ /* unknownFieldSet */
) =>
HeroDepartedEvent(
departingHeroId = departingHeroId,
fromFactionId = fromFactionId,
provinceId = provinceId
)
case SuppressBeastsFailedDetails(
heroId,
provinceId,
singularName,
pluralName,
_ /* unknownFieldSet */
) =>
SuppressBeastsFailedChronicleEvent(
date = date,
heroId = heroId,
provinceId = provinceId,
singularName = singularName,
pluralName = pluralName
)
case SuppressBeastsFailedDetails(
heroId,
provinceId,
_ /* singularName */,
pluralName,
_ /* unknownFieldSet */
) =>
SuppressBeastsFailedEvent(
heroId = heroId,
provinceId = provinceId,
pluralName = pluralName
)
case PrisonerExecutedDetails(
lastFactionId,
provinceId,
executedHeroId,
executingFactionId,
_ /* unknownFieldSet */
) =>
PrisonerExecutedChronicleEvent(
date = date,
lastFactionId = lastFactionId,
provinceId = provinceId,
executedHeroId = executedHeroId,
executingFactionId = executingFactionId
)
case PrisonerExecutedDetails(
lastFactionId,
provinceId,
executedHeroId,
executingFactionId,
_ /* unknownFieldSet */
) =>
PrisonerExecutedEvent(
lastFactionId = lastFactionId,
provinceId = provinceId,
executedHeroId = executedHeroId,
executingFactionId = executingFactionId
)
case PrisonerExchangeDetails(
hero1Id,
hero1FactionId,
hero1ProvinceId,
hero2Id,
hero2FactionId,
hero2ProvinceId,
_ /* unknownFieldSet */
) =>
PrisonersExchangedChronicleEvent(
date = date,
hero1Id = hero1Id,
hero1FactionId = hero1FactionId,
hero1ProvinceId = hero1ProvinceId,
hero2Id = hero2Id,
hero2FactionId = hero2FactionId,
hero2ProvinceId = hero2ProvinceId
)
case PrisonerExchangeDetails(
hero1Id,
hero1FactionId,
hero1ProvinceId,
hero2Id,
hero2FactionId,
hero2ProvinceId,
_ /* unknownFieldSet */
) =>
PrisonersExchangedEvent(
hero1Id = hero1Id,
hero1FactionId = hero1FactionId,
hero1ProvinceId = hero1ProvinceId,
hero2Id = hero2Id,
hero2FactionId = hero2FactionId,
hero2ProvinceId = hero2ProvinceId
)
case ProvinceConqueredDetails(
provinceId,
conqueringFactionId,
otherAttackingFactionIds,
defendingFactionId,
_ /* unknownFieldSet */
) =>
ProvinceConqueredChronicleEvent(
date = date,
provinceId = provinceId,
conqueringFactionId = conqueringFactionId,
otherAttackingFactionIds = otherAttackingFactionIds.toVector,
defendingFactionId = defendingFactionId
)
case ProvinceConqueredDetails(
provinceId,
conqueringFactionId,
otherAttackingFactionIds,
defendingFactionId,
_ /* unknownFieldSet */
) =>
ProvinceConqueredEvent(
provinceId = provinceId,
conqueringFactionId = conqueringFactionId,
otherAttackingFactionIds = otherAttackingFactionIds,
defendingFactionId = defendingFactionId
)
case ProvinceExpansionDetails(
provinceId,
expandingFactionId,
_ /* unknownFieldSet */
) =>
ProvinceExpansionChronicleEvent(
date = date,
provinceId = provinceId,
expandingFactionId = expandingFactionId
)
case ProvinceExpansionDetails(
provinceId,
expandingFactionId,
_ /* unknownFieldSet */
) =>
ProvinceExpansionEvent(
provinceId = provinceId,
expandingFactionId = expandingFactionId
)
case ProvinceHeldDetails(
provinceId,
attackingFids,
defendingFid,
_ /* unknownFieldSet */
) =>
ProvinceHeldChronicleEvent(
date = date,
provinceId = provinceId,
attackingFactionIds = attackingFids.toVector,
defendingFactionId = defendingFid
)
case ProvinceHeldDetails(
provinceId,
attackingFids,
defendingFid,
_ /* unknownFieldSet */
) =>
ProvinceHeldEvent(
provinceId = provinceId,
attackingFactionIds = attackingFids,
defendingFactionId = defendingFid
)
case SwearBrotherhoodDetails(
factionId,
newBrotherHeroId,
_ /* unknownFieldSet */
) =>
SwearBrotherhoodChronicleEvent(
date = date,
factionId = factionId,
newBrotherHeroId = newBrotherHeroId
)
case SwearBrotherhoodDetails(
factionId,
newBrotherHeroId,
_ /* unknownFieldSet */
) =>
SwearBrotherhoodEvent(
factionId = factionId,
newBrotherHeroId = newBrotherHeroId
)
case InvitationAcceptedDetails(
offeringFactionId,
invitedFactionId,
_ /* unknownFieldSet */
) =>
InvitationAcceptedChronicleEvent(
date = date,
offeringFactionId = offeringFactionId,
invitedFactionId = invitedFactionId
)
case InvitationAcceptedDetails(
offeringFactionId,
invitedFactionId,
_ /* unknownFieldSet */
) =>
InvitationAcceptedEvent(
offeringFactionId = offeringFactionId,
invitedFactionId = invitedFactionId
)
case InvitationAmbassadorImprisonedDetails(
offeringFactionId,
invitedFactionId,
ambassadorHeroId,
_ /* unknownFieldSet */
) =>
InvitationAmbassadorImprisonedChronicleEvent(
date = date,
offeringFactionId = offeringFactionId,
invitedFactionId = invitedFactionId,
ambassadorHeroId = ambassadorHeroId
)
case InvitationAmbassadorImprisonedDetails(
offeringFactionId,
invitedFactionId,
ambassadorHeroId,
_ /* unknownFieldSet */
) =>
InvitationAmbassadorImprisonedEvent(
offeringFactionId = offeringFactionId,
invitedFactionId = invitedFactionId,
ambassadorHeroId = ambassadorHeroId
)
case TruceAcceptedDetails(
offeringFactionId,
targetFactionId,
ambassadorHeroId,
_ /* unknownFieldSet */
) =>
TruceAcceptedChronicleEvent(
date = date,
offeringFactionId = offeringFactionId,
targetFactionId = targetFactionId,
ambassadorHeroId = ambassadorHeroId
)
case TruceAcceptedDetails(
offeringFactionId,
targetFactionId,
ambassadorHeroId,
_ /* unknownFieldSet */
) =>
TruceAcceptedEvent(
offeringFactionId = offeringFactionId,
targetFactionId = targetFactionId,
ambassadorHeroId = ambassadorHeroId
)
case TruceAmbassadorImprisonedDetails(
offeringFactionId,
targetFactionId,
ambassadorHeroId,
_ /* unknownFieldSet */
) =>
TruceAmbassadorImprisonedChronicleEvent(
date = date,
offeringFactionId = offeringFactionId,
targetFactionId = targetFactionId,
ambassadorHeroId = ambassadorHeroId
)
case TruceAmbassadorImprisonedDetails(
offeringFactionId,
targetFactionId,
ambassadorHeroId,
_ /* unknownFieldSet */
) =>
TruceAmbassadorImprisonedEvent(
offeringFactionId = offeringFactionId,
targetFactionId = targetFactionId,
ambassadorHeroId = ambassadorHeroId
)
case BreakAllianceAcceptedDetails(
offeringFactionId,
targetFactionId,
ambassadorHeroId,
_ /* unknownFieldSet */
) =>
BreakAllianceChronicleEvent(
date = date,
offeringFactionId = offeringFactionId,
targetFactionId = targetFactionId,
ambassadorHeroId = ambassadorHeroId
)
case BreakAllianceAcceptedDetails(
offeringFactionId,
targetFactionId,
ambassadorHeroId,
_ /* unknownFieldSet */
) =>
BreakAllianceEvent(
offeringFactionId = offeringFactionId,
targetFactionId = targetFactionId,
ambassadorHeroId = ambassadorHeroId
)
case BreakAllianceAmbassadorImprisonedDetails(
offeringFactionId,
targetFactionId,
ambassadorHeroId,
_ /* unknownFieldSet */
) =>
BreakAllianceAmbassadorImprisonedChronicleEvent(
date = date,
offeringFactionId = offeringFactionId,
targetFactionId = targetFactionId,
ambassadorHeroId = ambassadorHeroId
)
case BreakAllianceAmbassadorImprisonedDetails(
offeringFactionId,
targetFactionId,
ambassadorHeroId,
_ /* unknownFieldSet */
) =>
BreakAllianceAmbassadorImprisonedEvent(
offeringFactionId = offeringFactionId,
targetFactionId = targetFactionId,
ambassadorHeroId = ambassadorHeroId
)
case OutlawApprehendedDetails(
factionId,
provinceId,
apprehendedHeroId,
_ /* unknownFieldSet */
) =>
OutlawApprehendedChronicleEvent(
date = date,
factionId = factionId,
provinceId = provinceId,
apprehendedHeroId = apprehendedHeroId
)
case OutlawApprehendedDetails(
factionId,
provinceId,
apprehendedHeroId,
_ /* unknownFieldSet */
) =>
OutlawApprehendedEvent(
factionId = factionId,
provinceId = provinceId,
apprehendedHeroId = apprehendedHeroId
)
case RansomPaidDetails(
ransomedHeroId,
ransomPaidByFactionId,
ransomPaidToFactionId,
_ /* unknownFieldSet */
) =>
RansomPaidChronicleEvent(
date = date,
ransomedHeroId = ransomedHeroId,
ransomPaidByFactionId = ransomPaidByFactionId,
ransomPaidToFactionId = ransomPaidToFactionId
)
case RansomPaidDetails(
ransomedHeroId,
ransomPaidByFactionId,
ransomPaidToFactionId,
_ /* unknownFieldSet */
) =>
RansomPaidEvent(
ransomedHeroId = ransomedHeroId,
ransomPaidByFactionId = ransomPaidByFactionId,
ransomPaidToFactionId = ransomPaidToFactionId
)
case RansomRejectedDetails(
ransomedHeroId,
ransomRefusedByFactionId,
ransomOfferingFactionId,
_ /* unknownFieldSet */
) =>
RansomRejectedChronicleEvent(
date = date,
ransomedHeroId = ransomedHeroId,
ransomRejectedByFactionId = ransomRefusedByFactionId,
ransomOfferingFactionId = ransomOfferingFactionId
)
case RansomRejectedDetails(
ransomedHeroId,
ransomRefusedByFactionId,
ransomOfferingFactionId,
_ /* unknownFieldSet */
) =>
RansomRejectedEvent(
ransomedHeroId = ransomedHeroId,
ransomRejectedByFactionId = ransomRefusedByFactionId,
ransomOfferingFactionId = ransomOfferingFactionId
)
case ShatteredArmyDetails(
factionId,
provinceId,
reason,
_ /* unknownFieldSet */
) =>
ShatteredArmyChronicleEvent(
date = date,
factionId = factionId,
provinceId = provinceId,
reason = reason match {
case SHATTERED_ARMY_REASON_BLIZZARD =>
ShatteredArmyChronicleEvent.Blizzard
case SHATTERED_ARMY_REASON_WITHDRAW =>
ShatteredArmyChronicleEvent.Withdraw
case _ =>
ShatteredArmyChronicleEvent.Withdraw // Default fallback
}
)
case ShatteredArmyDetails(
factionId,
provinceId,
reason,
_ /* unknownFieldSet */
) =>
ShatteredArmyEvent(
factionId = factionId,
provinceId = provinceId,
reason = reason match {
case SHATTERED_ARMY_REASON_BLIZZARD =>
SHATTERED_ARMY_EVENT_REASON_BLIZZARD
case SHATTERED_ARMY_REASON_UNKNOWN_UNSPECIFIED =>
SHATTERED_ARMY_EVENT_REASON_UNKNOWN_UNSPECIFIED
case SHATTERED_ARMY_REASON_WITHDRAW =>
SHATTERED_ARMY_EVENT_REASON_WITHDRAW
case Unrecognized(value) =>
ShatteredArmyEvent.Reason.Unrecognized(value)
}
)
case VassalExiledDetails(
factionId,
provinceId,
exiledHeroId,
_ /* unknownFieldSet */
) =>
VassalExiledChronicleEvent(
date = date,
factionId = factionId,
provinceId = provinceId,
exiledHeroId = exiledHeroId
)
}
case VassalExiledDetails(
factionId,
provinceId,
exiledHeroId,
_ /* unknownFieldSet */
) =>
VassalExiledEvent(
factionId = factionId,
provinceId = provinceId,
exiledHeroId = exiledHeroId
)
}
detailList.map { details =>
EventForChronicle(
awrs.gameState.currentDate,
details = details
)
}
}
private def basicActionResultEntries(
awrs: ActionWithResultingState
): Vector[ChronicleEvent] =
): Vector[EventForChronicle] =
awrs.actionResult.`type` match {
case FACTION_DESTROYED =>
val date = DateConverter.fromProto(awrs.gameState.currentDate)
awrs.actionResult.removedFactionIds.map { factionId =>
FactionDestroyedChronicleEvent(
date = date,
factionId = factionId
EventForChronicle(
date = awrs.gameState.currentDate,
details = FactionDestroyedEvent(factionId = factionId)
)
}.toVector
@@ -390,7 +407,7 @@ object ChronicleEventGenerator {
def eventTextEntries(
gameHistory: GameHistory,
since: Date
): Vector[ChronicleEvent] =
): Vector[EventForChronicle] =
gameHistory.sinceDate(since).flatMap { (awrs: ActionWithResultingState) =>
notificationEntries(awrs) ++ basicActionResultEntries(awrs)
}
@@ -31,6 +31,7 @@ import net.eagle0.eagle.model.action_result.concrete.{
import net.eagle0.eagle.model.action_result.generated_text_request.{ChronicleUpdatePreviousEntry, LlmRequestT}
import net.eagle0.eagle.model.action_result.types.NewRoundActionResultType
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.generated_text_request.chronicle_event.ChronicleEventConverter
import net.eagle0.eagle.model.state.chronicle_entry.ChronicleEntry
import net.eagle0.eagle.model.state.date.Date
import net.eagle0.eagle.model.state.faction.FactionRelationship
@@ -116,12 +117,14 @@ case class NewRoundAction(
generatedTextId = entry.generatedTextId
)
},
new_entries = ChronicleEventGenerator.eventTextEntries(
gameHistory = gameHistory,
since = gs.chronicleEntries.lastOption
.map(_.date)
.getOrElse(Date(year = 0, month = Date.Month.January))
)
new_entries = ChronicleEventGenerator
.eventTextEntries(
gameHistory = gameHistory,
since = gs.chronicleEntries.lastOption
.map(_.date)
.getOrElse(Date(year = 0, month = Date.Month.January))
)
.map(ChronicleEventConverter.fromProto)
)
)
else Vector()
@@ -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]
}
@@ -1410,6 +1410,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/settings:train_charisma_xp",
"//src/main/scala/net/eagle0/eagle/library/settings:train_stat_divisor",
"//src/main/scala/net/eagle0/eagle/library/settings:train_strength_xp",
"//src/main/scala/net/eagle0/eagle/library/util:battalion_type_finder",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:notification_trait",
@@ -189,6 +189,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:vigor_to_constitution_xp_multiplier",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
@@ -1,10 +1,22 @@
package net.eagle0.eagle.library.actions.impl.common
import net.eagle0.eagle.internal.action_result.ActionResult
import net.eagle0.eagle.library.settings.VigorToConstitutionXpMultiplier
import net.eagle0.eagle.model.action_result.concrete.{ChangedHeroC, StatAbsolute, StatDelta, StatNoChange}
import net.eagle0.eagle.model.action_result.ActionResultT
object VigorXPApplier {
def withVigorXp(actionResult: ActionResult): ActionResult =
actionResult.update(
_.changedHeroes.modify(_.map { ch =>
ch.update(
_.optionalConstitutionXpDelta := ch.vigor.vigorDelta
.filter(_ < 0)
.map(vigor => (vigor * -VigorToConstitutionXpMultiplier.doubleValue).ceil.toInt)
)
})
)
def withVigorXp(actionResult: ActionResultT): ActionResultT =
actionResult.copy(
changedHeroes = actionResult.changedHeroes.map {
@@ -347,24 +347,6 @@ scala_library(
deps = [],
)
scala_library(
name = "new_faction_head_prompt_generator",
srcs = ["NewFactionHeadPromptGenerator.scala"],
visibility = ["//visibility:public"],
deps = [
":faction_info_utilities",
":generator_utilities",
":hero_description_generator",
":hero_info_utilities",
":llm_prompt_generator",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
],
)
scala_library(
name = "please_recruit_me_prompt_generator",
srcs = ["PleaseRecruitMePromptGenerator.scala"],
@@ -1,73 +0,0 @@
package net.eagle0.eagle.library.actions.llm_prompt_generators
import net.eagle0.eagle.client_text.{ClientTextStore, TextGenerationResult}
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.generated_text_request.NewFactionHeadMessage
case class NewFactionHeadPromptGenerator(
newFactionHeadMessage: NewFactionHeadMessage,
gameState: GameState,
clientTextStore: ClientTextStore
) extends LLMPromptGenerator {
private val newHeadHero = HeroInfoUtilities.getHero(
newFactionHeadMessage.newHeadHeroId,
gameState
)
private val previousHeadHero = HeroInfoUtilities.getHero(
newFactionHeadMessage.previousHeadHeroId,
gameState
)
private val faction = FactionInfoUtilities.getFaction(
newFactionHeadMessage.factionId,
gameState
)
override def generate: TextGenerationResult = for {
newHeadHeroDescription <- HeroDescriptionGenerator.description(
heroId = newFactionHeadMessage.newHeadHeroId,
gameState = gameState,
clientTextStore = clientTextStore
)
previousHeadHeroDescription <- HeroDescriptionGenerator.description(
heroId = newFactionHeadMessage.previousHeadHeroId,
gameState = gameState,
clientTextStore = clientTextStore
)
newHeadHeroName <- clientTextStore.getText(newHeadHero.nameTextId)
previousHeadHeroName <- clientTextStore.getText(previousHeadHero.nameTextId)
setup <- GeneratorUtilities.basicSetup(gameState, clientTextStore)
} yield {
val previousFactionName = newFactionHeadMessage.previousFactionName
val newFactionName = Option(newFactionHeadMessage.newFactionName).filter(_.nonEmpty)
val renameClause = newFactionName match {
case Some(newName) =>
s" $newHeadHeroName is also renaming the faction from \"$previousFactionName\" to \"$newName\"."
case None => ""
}
val possP = HeroDescriptionGenerator.possessivePronoun(newHeadHero.pronounGender)
val inputText =
s"""$setup
|
|NEW FACTION HEAD:
|$newHeadHeroDescription
|
|PREVIOUS FACTION HEAD (EXECUTED):
|$previousHeadHeroDescription
|
|$previousHeadHeroName, the previous head of the faction "$previousFactionName", has been executed.
|$newHeadHeroName has now become the new head of the faction.$renameClause
|
|Write a message from $newHeadHeroName to be delivered to the world, declaring $possP new leadership
|in the wake of $previousHeadHeroName's execution. The message should reflect $possP personality and
|ambitions, and may reference the circumstances of $possP rise to power or their relationship with
|the previous leader. If the faction is being renamed, the declaration should explain why the new
|name represents $possP vision for the faction.
|
|${GeneratorUtilities.limitations}""".stripMargin
inputText
}
}
@@ -1,7 +1,6 @@
package net.eagle0.eagle.library.actions.random_state_sequencer
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.library.actions.applier.{ActionResultApplier, ActionResultWithResultingState}
import net.eagle0.eagle.library.actions.impl.common.{
ProtolessRandomSimpleAction,
@@ -80,15 +79,6 @@ trait RandomStateSequencer {
case TCommand.RandomSimple(action) => withProtolessRandomSimpleAction(_ => action)
case TCommand.Sequential(action) => withProtolessSequentialResultsAction(_ => action)
}
/**
* Executes a TCommand and wraps the first result with lastCommandTypeForActingProvince. This mimics the old
* Command.resultWithLastCommand behavior.
*/
def withTCommandAndLastCommand(
commandGen: GameState => TCommand,
selectedCommand: SelectedCommand
): RandomStateSequencer
}
object RandomStateSequencer {
@@ -186,29 +176,4 @@ private case class RandomStateSequencerImpl(
case (sequencer, t) =>
sequencer.withRandomActionResult((gs, fr) => f(t, gs, fr))
}
override def withTCommandAndLastCommand(
commandGen: GameState => TCommand,
selectedCommand: SelectedCommand
): RandomStateSequencer = {
def wrapWithLastCommand(result: ActionResultT): ActionResultT =
result.withLastCommandTypeForActingProvince(selectedCommand)
commandGen(lastState) match {
case TCommand.Simple(action) =>
withActionResult(_ => wrapWithLastCommand(action.immediateExecute))
case TCommand.RandomSimple(action) =>
withRandomActionResult((gs, fr) => action.immediateExecute(fr).map(wrapWithLastCommand))
case TCommand.Sequential(action) =>
action.results.toVector match {
case head +: tail =>
withActionResult(_ => wrapWithLastCommand(head))
.withActionResults(_ => tail)
case empty =>
this
}
}
}
}
@@ -35,6 +35,43 @@ scala_library(
],
)
scala_library(
name = "battalion_suitability",
srcs = ["BattalionSuitability.scala"],
visibility = [
"__subpackages__",
"//src/main/scala/net/eagle0/eagle/library/actions/availability:__pkg__",
"//src/test/scala/net/eagle0/eagle/library/util:__pkg__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api/command/util:appropriate_battalions_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/common:profession_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:hero_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
],
)
scala_library(
name = "battalion_type_finder",
srcs = ["BattalionTypeFinder.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle/ai:__pkg__",
"//src/main/scala/net/eagle0/eagle/library:__subpackages__",
"//src/main/scala/net/eagle0/util:__pkg__",
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
],
exports = [
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_scala_proto",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_scala_proto",
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
],
)
scala_library(
name = "battalion_power",
srcs = ["BattalionPower.scala"],
@@ -274,24 +311,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,4 +1,4 @@
package net.eagle0.eagle.library.util.battalion_suitability
package net.eagle0.eagle.library.util
import net.eagle0.eagle.api.command.util.appropriate_battalions.SuitableBattalions
import net.eagle0.eagle.api.command.util.appropriate_battalions.SuitableBattalions.{
@@ -10,25 +10,25 @@ import net.eagle0.eagle.api.command.util.appropriate_battalions.SuitableBattalio
RESTRICTIVE,
SUBOPTIMAL
}
import net.eagle0.eagle.model.state.battalion.BattalionT
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.hero.Profession.{Engineer, Mage, Necromancer, Ranger}
import net.eagle0.eagle.model.state.BattalionType
import net.eagle0.eagle.common.battalion_type.BattalionType
import net.eagle0.eagle.common.profession.Profession.{ENGINEER, MAGE, NECROMANCER, RANGER}
import net.eagle0.eagle.internal.battalion.Battalion
import net.eagle0.eagle.internal.hero.Hero
import net.eagle0.eagle.HeroId
object LegacyBattalionSuitability {
def suitabilityLevel(h: HeroT, bt: BattalionType): SuitabilityLevel =
object BattalionSuitability {
def suitabilityLevel(h: Hero, bt: BattalionType): SuitabilityLevel =
h.profession match {
case Mage | Necromancer =>
case MAGE | NECROMANCER =>
if !bt.allowsCasting then RESTRICTIVE
else if bt.alwaysArcheryCapable then SUBOPTIMAL
else OPTIMAL
case Engineer =>
case ENGINEER =>
if bt.alwaysArcheryCapable then SUBOPTIMAL
else OPTIMAL
case Ranger =>
case RANGER =>
if !bt.allowsStealth then RESTRICTIVE
else OPTIMAL
@@ -36,23 +36,23 @@ object LegacyBattalionSuitability {
}
def suitableBattalions(
h: HeroT,
bs: Vector[BattalionT],
h: Hero,
bs: Vector[Battalion],
bts: Vector[BattalionType]
): SuitableBattalions =
SuitableBattalions(
battalionIdsWithSuitability = bs.map(b =>
BattalionIdWithSuitability(
battalionId = b.id,
level = LegacyBattalionSuitability
.suitabilityLevel(h, bts.find(_.typeId == b.typeId).get)
level = BattalionSuitability
.suitabilityLevel(h, bts.find(_.typeId == b.`type`).get)
)
)
)
def suitableBattalionsForHeroes(
hs: Vector[HeroT],
bs: Vector[BattalionT],
hs: Vector[Hero],
bs: Vector[Battalion],
bts: Vector[BattalionType]
): Map[HeroId, SuitableBattalions] =
hs.map(h => h.id -> suitableBattalions(h, bs, bts)).toMap
@@ -1,4 +1,4 @@
package net.eagle0.eagle.library.util.battalion_type_finder
package net.eagle0.eagle.library.util
import net.eagle0.eagle.common.battalion_type.{
BattalionType as BattalionTypeProto,
@@ -6,7 +6,7 @@ import net.eagle0.eagle.common.battalion_type.{
}
import net.eagle0.eagle.model.state.BattalionTypeId
object LegacyBattalionTypeFinder {
object BattalionTypeFinder {
def battalionType(
battalionTypeId: BattalionTypeId,
battalionTypes: Vector[BattalionTypeProto]
@@ -194,8 +194,7 @@ object GameStateViewDiffer {
changedFactionRelationships = after.factionRelationships.diff(before.factionRelationships),
addedIncomingDiplomacyOffers = after.incomingDiplomacyOffers.diff(before.incomingDiplomacyOffers),
removedIncomingDiplomacyOffers = before.incomingDiplomacyOffers.diff(after.incomingDiplomacyOffers),
newFocusProvinceId = after.focusProvinceId,
newName = optionalDiff(before.name, after.name)
newFocusProvinceId = after.focusProvinceId
)
)

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