mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 09:15:42 +00:00
Compare commits
81
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0484a74fa | ||
|
|
672cb5cb20 | ||
|
|
816d2b6859 | ||
|
|
19b305829b | ||
|
|
3c382349e6 | ||
|
|
53314e7cb6 | ||
|
|
d4a5f48103 | ||
|
|
d6d1868fcb | ||
|
|
8c9d38c546 | ||
|
|
139494d937 | ||
|
|
5c52dd9006 | ||
|
|
d473e5a5d6 | ||
|
|
e9b980e6d0 | ||
|
|
8ba4268832 | ||
|
|
3a033ddf19 | ||
|
|
61196d05e7 | ||
|
|
e5a71bd3b4 | ||
|
|
e545d9c616 | ||
|
|
17e5135915 | ||
|
|
5c9623717d | ||
|
|
2ed1c5769e | ||
|
|
7a3b382623 | ||
|
|
d1e4ba4efe | ||
|
|
03c26b01c9 | ||
|
|
830c524c53 | ||
|
|
c348085439 | ||
|
|
b0f5f05362 | ||
|
|
92b41c9b36 | ||
|
|
5c83fa479d | ||
|
|
831b72b382 | ||
|
|
d3c1542eaa | ||
|
|
c7b5e2db0d | ||
|
|
9ac664659f | ||
|
|
4c37d33682 | ||
|
|
71c7d700a5 | ||
|
|
ab485919ed | ||
|
|
8725ccfb34 | ||
|
|
73372d82bf | ||
|
|
f21839df81 | ||
|
|
fc1d5aa7ad | ||
|
|
c7cacb4915 | ||
|
|
d26a2322e3 | ||
|
|
c9d252093a | ||
|
|
7c31faaee0 | ||
|
|
3fab4b94f1 | ||
|
|
0ff8a3a8e1 | ||
|
|
ea70bbb2ac | ||
|
|
8e14f4cdbb | ||
|
|
6ffbbe242e | ||
|
|
84108b995a | ||
|
|
e4adac2eed | ||
|
|
3b83abe4c6 | ||
|
|
355c06d342 | ||
|
|
dfd1da8b8a | ||
|
|
ded08187b2 | ||
|
|
8747682842 | ||
|
|
ac5fa9a495 | ||
|
|
d65e486f36 | ||
|
|
9b719cafa8 | ||
|
|
87218d7a72 | ||
|
|
f7a0d562fe | ||
|
|
01ffbb8234 | ||
|
|
aa4f58c0c4 | ||
|
|
61c2d92b01 | ||
|
|
08dedebf7d | ||
|
|
c673f57179 | ||
|
|
29ec88c6e2 | ||
|
|
d50de9da3f | ||
|
|
9340500b22 | ||
|
|
1433f3d29c | ||
|
|
331702bf3c | ||
|
|
fbb04270a5 | ||
|
|
b8599d9088 | ||
|
|
d7e29eeb89 | ||
|
|
6f6c205f11 | ||
|
|
15e5d82504 | ||
|
|
b7ff76a38e | ||
|
|
e0d5a9f2ec | ||
|
|
bf02de3190 | ||
|
|
cbc14e5131 | ||
|
|
9974f32ebb |
@@ -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
|
||||
common --linkopt=-Wl
|
||||
common:macos --linkopt=-Wl,-no_warn_duplicate_libraries
|
||||
# Use host_linkopt for macOS-specific flags to avoid passing them to Linux cross-compilation
|
||||
common:macos --host_linkopt=-Wl,-no_warn_duplicate_libraries
|
||||
|
||||
# Fix Xcode version caching issue - avoids need for `bazel clean --expunge` after Xcode updates
|
||||
common:macos --repo_env=DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
|
||||
|
||||
@@ -5,6 +5,7 @@ on:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 'src/main/cpp/**'
|
||||
- 'src/main/go/**'
|
||||
- 'src/main/scala/**'
|
||||
- 'src/main/protobuf/**'
|
||||
- 'src/main/resources/**'
|
||||
@@ -25,6 +26,8 @@ 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
|
||||
@@ -32,7 +35,15 @@ jobs:
|
||||
lfs: false
|
||||
|
||||
- name: Build Eagle Docker image
|
||||
run: bazel build //ci:eagle_server_image
|
||||
id: build-eagle
|
||||
run: |
|
||||
set -ex
|
||||
bazel build --platforms=//:linux_x86_64 //ci:eagle_server_image
|
||||
|
||||
# Save the resolved path before any other bazel command changes bazel-bin symlink
|
||||
IMAGE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
|
||||
echo "Image path: $IMAGE_PATH"
|
||||
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to DigitalOcean Container Registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
@@ -47,21 +58,163 @@ jobs:
|
||||
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: bazel run //ci:eagle_server_push
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
# Use cross-compiled image path from build step
|
||||
EAGLE_IMAGE="${{ steps.build-eagle.outputs.image_path }}"
|
||||
echo "Using Eagle image: $EAGLE_IMAGE"
|
||||
|
||||
if [ -z "$EAGLE_IMAGE" ] || [ ! -d "$EAGLE_IMAGE" ]; then
|
||||
echo "ERROR: Eagle image not found at: $EAGLE_IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Debug: show OCI layout contents
|
||||
echo "=== OCI Layout Contents ==="
|
||||
cat "$EAGLE_IMAGE/index.json"
|
||||
echo ""
|
||||
echo "=== Blobs ==="
|
||||
ls -la "$EAGLE_IMAGE/blobs/sha256/" | head -20
|
||||
|
||||
# Verify OCI layout consistency before pushing
|
||||
echo "=== Verifying OCI layout consistency ==="
|
||||
for digest in $(cat "$EAGLE_IMAGE/index.json" | grep -o '"sha256:[^"]*"' | tr -d '"'); do
|
||||
blob_path="$EAGLE_IMAGE/blobs/${digest/://}"
|
||||
if [ ! -f "$blob_path" ]; then
|
||||
echo "ERROR: Blob not found: $blob_path"
|
||||
exit 1
|
||||
fi
|
||||
actual_digest="sha256:$(shasum -a 256 "$blob_path" | cut -d' ' -f1)"
|
||||
if [ "$digest" != "$actual_digest" ]; then
|
||||
echo "ERROR: Digest mismatch for $blob_path"
|
||||
echo " Index says: $digest"
|
||||
echo " Actual: $actual_digest"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Verified: $digest"
|
||||
done
|
||||
|
||||
# Build the push target to get crane in runfiles
|
||||
bazel build //ci:eagle_server_push
|
||||
|
||||
# Use crane directly for push (avoids OCI->Docker digest mismatch)
|
||||
CRANE="bazel-bin/ci/push_eagle_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
|
||||
echo "Using crane: $CRANE"
|
||||
|
||||
# Push with SHA tag
|
||||
GIT_SHA=$(git rev-parse --short=8 HEAD)
|
||||
IMAGE_TAG="registry.digitalocean.com/eagle0/eagle-server:${GIT_SHA}"
|
||||
echo "Pushing eagle image: $IMAGE_TAG"
|
||||
$CRANE push "$EAGLE_IMAGE" "$IMAGE_TAG"
|
||||
|
||||
# Verify push by checking what's in the registry
|
||||
echo "=== Verifying push ==="
|
||||
$CRANE manifest "$IMAGE_TAG" | head -50
|
||||
PUSHED_DIGEST=$($CRANE digest "$IMAGE_TAG")
|
||||
echo "Registry reports digest: $PUSHED_DIGEST"
|
||||
|
||||
# Output the full image tag for deploy step
|
||||
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Also update :latest for convenience (but deploy won't use it)
|
||||
echo "Copying to :latest tag"
|
||||
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest"
|
||||
|
||||
build-shardok:
|
||||
runs-on: self-hosted
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Build Shardok Docker image (cross-compile for Linux)
|
||||
run: bazel build --platforms=//:linux_x86_64 //ci:shardok_server_image
|
||||
- name: Build Shardok binary (cross-compile for Linux)
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
# Step 1: Build JUST the binary with cross-compilation
|
||||
# We need --extra_toolchains to force the Linux toolchain to be used
|
||||
# because toolchains_llvm registers with dev_dependency=True
|
||||
echo "=== Building shardok-server binary for linux-x86_64 ==="
|
||||
bazel build \
|
||||
--platforms=//:linux_x86_64 \
|
||||
--extra_toolchains=@llvm_toolchain_linux//:all \
|
||||
//src/main/cpp/net/eagle0/shardok:shardok-server
|
||||
|
||||
# Step 2: Check the binary directly from bazel-bin
|
||||
# bazel-bin is a symlink that points to the correct output directory
|
||||
LINUX_BIN="bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server"
|
||||
echo "=== Checking binary at: $LINUX_BIN ==="
|
||||
|
||||
if [ ! -f "$LINUX_BIN" ]; then
|
||||
echo "ERROR: Binary not found at $LINUX_BIN"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Debug: show what bazel-bin points to
|
||||
echo "bazel-bin symlink target: $(readlink bazel-bin || echo 'not a symlink')"
|
||||
|
||||
# Step 3: Verify it's ELF (Linux) not Mach-O (macOS)
|
||||
echo "=== Verifying binary format ==="
|
||||
MAGIC=$(head -c 4 "$LINUX_BIN" | xxd -p)
|
||||
echo "Binary magic bytes: $MAGIC"
|
||||
|
||||
if [ "$MAGIC" = "7f454c46" ]; then
|
||||
echo "SUCCESS: Binary is ELF format (Linux)"
|
||||
elif [ "$MAGIC" = "cfaeedfe" ] || [ "$MAGIC" = "cffaedfe" ]; then
|
||||
echo "ERROR: Binary is Mach-O format (macOS) - cross-compilation failed!"
|
||||
echo ""
|
||||
echo "Debug info:"
|
||||
echo "- bazel-bin points to: $(readlink bazel-bin)"
|
||||
file "$LINUX_BIN" || true
|
||||
exit 1
|
||||
else
|
||||
echo "WARNING: Unknown binary format: $MAGIC"
|
||||
file "$LINUX_BIN" || true
|
||||
fi
|
||||
|
||||
- name: Build Shardok Docker image
|
||||
id: build-shardok
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
# Build the OCI image with cross-compilation flags
|
||||
bazel build \
|
||||
--platforms=//:linux_x86_64 \
|
||||
--extra_toolchains=@llvm_toolchain_linux//:all \
|
||||
//ci:shardok_server_image
|
||||
|
||||
# The image is output to bazel-bin which is a symlink.
|
||||
# Resolve it now before any other bazel commands change where it points.
|
||||
IMAGE_PATH=$(readlink -f bazel-bin/ci/shardok_server_image)
|
||||
echo "Image path: $IMAGE_PATH"
|
||||
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
# Verify the binary inside the tar layer is ELF
|
||||
echo "=== Verifying binary in image tar ==="
|
||||
BINARY_TAR="bazel-bin/ci/shardok_binary_layer.tar"
|
||||
if [ -f "$BINARY_TAR" ]; then
|
||||
echo "Checking binary in $BINARY_TAR"
|
||||
# Extract just the first 4 bytes of the binary from the tar
|
||||
MAGIC=$(tar -xOf "$BINARY_TAR" app/shardok-server 2>/dev/null | head -c 4 | xxd -p)
|
||||
echo "Binary magic in tar: $MAGIC"
|
||||
if [ "$MAGIC" = "7f454c46" ]; then
|
||||
echo "SUCCESS: Binary in tar is ELF format (Linux)"
|
||||
else
|
||||
echo "ERROR: Binary in tar is NOT ELF format!"
|
||||
echo "This means pkg_tar is packaging the wrong binary."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "WARNING: Could not find $BINARY_TAR"
|
||||
fi
|
||||
|
||||
- name: Login to DigitalOcean Container Registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
@@ -76,7 +229,228 @@ jobs:
|
||||
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: bazel run --platforms=//:linux_x86_64 //ci:shardok_server_push
|
||||
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
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Deproto Migration Plan
|
||||
|
||||
This document tracks the migration from protobuf types to native Scala models inside the Eagle game engine.
|
||||
|
||||
## Architectural Decisions
|
||||
|
||||
1. **Keep proto for persistence**: Yes - protobuf is used for persisting game state
|
||||
2. **Keep proto for Shardok communication**: Yes - protobuf is used for Eagle-Shardok gRPC communication
|
||||
3. **Use Scala views inside the library**: Yes - use native Scala types like `ProvinceView`, `FactionView`, `HeroView`, etc. within the library code
|
||||
|
||||
## Migration Pattern
|
||||
|
||||
The codebase follows a **Legacy* pattern** for separating proto-dependent and protoless code:
|
||||
|
||||
- **Protoless utilities**: `FactionUtils`, `HeroUtils`, `ProvinceUtils`, `ProvinceDistances`, etc.
|
||||
- **Proto-dependent utilities**: `LegacyFactionUtils`, `LegacyHeroUtils`, `LegacyProvinceUtils`, `LegacyProvinceDistances`, etc.
|
||||
|
||||
When migrating a file:
|
||||
1. Create a `Legacy*` version containing the proto-dependent methods
|
||||
2. Keep the original file name for protoless methods
|
||||
3. Update callers to use the appropriate version based on their context
|
||||
|
||||
## Migration Status
|
||||
|
||||
### Fully Protoless (no proto imports)
|
||||
|
||||
**Utilities:**
|
||||
- [x] `FactionUtils` - has protoless `ownedNeighbors` method
|
||||
- [x] `ProvinceDistances` - split into protoless + `LegacyProvinceDistances`
|
||||
- [x] `SwornBrotherChooser` - fully protoless (removed `bestChoiceProto`)
|
||||
|
||||
**Command Selectors (all use native GameState):**
|
||||
- [x] `AllianceOfferCommandSelector`
|
||||
- [x] `AlmsCommandSelector`
|
||||
- [x] `AttackCommandChooser`
|
||||
- [x] `ExpandCommandSelector`
|
||||
- [x] `HeroGiftCommandSelector`
|
||||
- [x] `ImproveCommandSelector`
|
||||
- [x] `OrganizeCommandSelector`
|
||||
- [x] `RansomOfferHelpers`
|
||||
- [x] `SeekMoreLeadersCommandChooser` - in AI folder, uses native GameState
|
||||
- [x] `TruceOfferCommandSelector`
|
||||
- [x] `TrustForDiplomacy`
|
||||
|
||||
**Quest Command Selectors (all protoless):**
|
||||
- [x] `AllianceQuestCommandChooser`
|
||||
- [x] `AlmsAcrossRealmQuestCommandChooser`
|
||||
- [x] `AlmsToProvinceQuestCommandChooser`
|
||||
- [x] `DismissSpecificVassalCommandChooser`
|
||||
- [x] `GiveToHeroesAcrossRealmQuestCommandChooser`
|
||||
- [x] `GiveToHeroesInProvinceQuestCommandChooser`
|
||||
- [x] `ImproveQuestCommandChooser`
|
||||
- [x] `QuestCommandChooser`
|
||||
- [x] `TruceCountQuestCommandChooser`
|
||||
- [x] `TruceWithFactionQuestCommandChooser`
|
||||
|
||||
### Dual (both proto and protoless versions)
|
||||
|
||||
- [~] `ProvinceGoldSurplusCalculator` - protoless `provinceGoldSurplus(province: ProvinceT)` + legacy `provinceGoldSurplus(provinceId, gameState)`
|
||||
- [~] `HeroSelector` - protoless `minimallyFatiguedHeroes` + legacy `minimallyFatiguedHeroesProto`
|
||||
|
||||
### Blocked (still uses proto GameState)
|
||||
|
||||
- [ ] `CommandChoiceHelpers` - main target, uses proto GameState extensively
|
||||
- Only 1 call to `minimallyFatiguedHeroesProto` (HeroSelector)
|
||||
- Many calls to proto `provinceGoldSurplus`
|
||||
- Depends on many Legacy* utils
|
||||
- [ ] `AttackDecisionCommandChooser` - uses proto types
|
||||
- [ ] `CommandChooser` - uses proto GameState
|
||||
- [ ] `FulfillQuestsCommandSelector` - takes proto, converts to native immediately
|
||||
- Called by `MidGameAIClient` which uses proto GameState
|
||||
|
||||
## Key Files
|
||||
|
||||
### Protoless Model Types
|
||||
|
||||
- `src/main/scala/net/eagle0/eagle/model/state/game_state/GameState.scala` - native Scala GameState
|
||||
- `src/main/scala/net/eagle0/eagle/model/state/province/ProvinceView.scala` - province view type
|
||||
- `src/main/scala/net/eagle0/eagle/model/state/faction/FactionView.scala` - faction view type
|
||||
- `src/main/scala/net/eagle0/eagle/model/state/hero/HeroView.scala` - hero view type
|
||||
|
||||
### Proto Converters
|
||||
|
||||
- `src/main/scala/net/eagle0/eagle/model/proto_converters/game_state/` - converts between proto and Scala types
|
||||
|
||||
## Notes
|
||||
|
||||
- The AI client code (`src/main/scala/net/eagle0/eagle/ai/`) currently uses proto types extensively
|
||||
- `PerformUnaffiliatedHeroesAction` already uses protoless `GameState`
|
||||
- Migration should proceed incrementally: utilities first, then higher-level selectors/choosers
|
||||
+26
-4
@@ -72,8 +72,8 @@ use_repo(llvm, "llvm_toolchain", "llvm_toolchain_linux")
|
||||
sysroot = use_repo_rule("@toolchains_llvm//toolchain:sysroot.bzl", "sysroot")
|
||||
sysroot(
|
||||
name = "linux_sysroot",
|
||||
sha256 = "aadb60a2e2c776ac000bb2e29f3039fd81b5b031500cf4a1651b365712819d1f",
|
||||
urls = ["https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/v2/ubuntu_noble_amd64_sysroot.tar.xz"],
|
||||
sha256 = "a06475004fe8003ae7ccb4fe1d5511feb9b27cce4a8826eb1dfd686ed83f3dba",
|
||||
urls = ["https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/v3/ubuntu_noble_amd64_sysroot.tar.xz"],
|
||||
)
|
||||
|
||||
#
|
||||
@@ -145,7 +145,15 @@ oci.pull(
|
||||
platforms = ["linux/amd64"],
|
||||
tag = "24.04",
|
||||
)
|
||||
use_repo(oci, "eclipse_temurin_17", "eclipse_temurin_17_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64")
|
||||
|
||||
# Base image for Admin Server (Alpine for lightweight Go binary)
|
||||
oci.pull(
|
||||
name = "alpine_linux",
|
||||
image = "docker.io/library/alpine",
|
||||
platforms = ["linux/amd64"],
|
||||
tag = "3.21",
|
||||
)
|
||||
use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_17", "eclipse_temurin_17_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64")
|
||||
|
||||
#
|
||||
# Java/Scala Dependencies
|
||||
@@ -205,9 +213,12 @@ maven.install(
|
||||
"org.reactivestreams:reactive-streams:1.0.4",
|
||||
"javax.xml.bind:jaxb-api:2.3.1",
|
||||
|
||||
# OkHttp (for SSE with read timeout support)
|
||||
# OkHttp (for SSE with read timeout support, OAuth HTTP calls)
|
||||
"com.squareup.okhttp3:okhttp:4.12.0",
|
||||
"com.squareup.okhttp3:okhttp-sse:4.12.0",
|
||||
|
||||
# JWT (for OAuth token handling)
|
||||
"com.nimbusds:nimbus-jose-jwt:9.37.3",
|
||||
],
|
||||
duplicate_version_warning = "error",
|
||||
fail_if_repin_required = True,
|
||||
@@ -223,6 +234,7 @@ 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"
|
||||
@@ -251,6 +263,16 @@ 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
|
||||
#
|
||||
|
||||
Generated
+35
-2
@@ -1293,7 +1293,7 @@
|
||||
"@@rules_oci~//oci:extensions.bzl%oci": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "FaY+7xb13bB3hmxqwAWaGp3Tf3Q4Nfdlr+F38CP5mcg=",
|
||||
"usagesDigest": "BuciKSozbpJMD9EP+j0RG5ZgrYMeDPsQyiOnLUni2V8=",
|
||||
"usagesDigest": "KXZUVR9ea29hTmhxC4+BG0pTXTijLpsFrDVraHG4OyU=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
"envVariables": {},
|
||||
@@ -1360,6 +1360,37 @@
|
||||
"reproducible": true
|
||||
}
|
||||
},
|
||||
"alpine_linux_linux_amd64": {
|
||||
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
|
||||
"ruleClassName": "oci_pull",
|
||||
"attributes": {
|
||||
"www_authenticate_challenges": {},
|
||||
"scheme": "https",
|
||||
"registry": "index.docker.io",
|
||||
"repository": "library/alpine",
|
||||
"identifier": "3.21",
|
||||
"platform": "linux/amd64",
|
||||
"target_name": "alpine_linux_linux_amd64",
|
||||
"bazel_tags": []
|
||||
}
|
||||
},
|
||||
"alpine_linux": {
|
||||
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
|
||||
"ruleClassName": "oci_alias",
|
||||
"attributes": {
|
||||
"target_name": "alpine_linux",
|
||||
"www_authenticate_challenges": {},
|
||||
"scheme": "https",
|
||||
"registry": "index.docker.io",
|
||||
"repository": "library/alpine",
|
||||
"identifier": "3.21",
|
||||
"platforms": {
|
||||
"@@platforms//cpu:x86_64": "@alpine_linux_linux_amd64"
|
||||
},
|
||||
"bzlmod_repository": "alpine_linux",
|
||||
"reproducible": true
|
||||
}
|
||||
},
|
||||
"oci_crane_darwin_amd64": {
|
||||
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
|
||||
"ruleClassName": "crane_repositories",
|
||||
@@ -1496,7 +1527,9 @@
|
||||
"eclipse_temurin_17",
|
||||
"eclipse_temurin_17_linux_amd64",
|
||||
"ubuntu_24_04",
|
||||
"ubuntu_24_04_linux_amd64"
|
||||
"ubuntu_24_04_linux_amd64",
|
||||
"alpine_linux",
|
||||
"alpine_linux_linux_amd64"
|
||||
],
|
||||
"explicitRootModuleDirectDevDeps": [],
|
||||
"useAllRepos": "NO",
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
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
|
||||
#
|
||||
@@ -45,6 +61,7 @@ oci_image(
|
||||
},
|
||||
exposed_ports = ["40032/tcp"],
|
||||
tars = [
|
||||
":busybox_layer",
|
||||
":eagle_server_jar_layer",
|
||||
":eagle_resources_layer",
|
||||
],
|
||||
@@ -59,6 +76,9 @@ 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",
|
||||
@@ -106,6 +126,7 @@ oci_image(
|
||||
"40052/tcp",
|
||||
],
|
||||
tars = [
|
||||
":busybox_layer",
|
||||
":shardok_binary_layer",
|
||||
":shardok_resources_layer",
|
||||
":shardok_maps_layer",
|
||||
@@ -121,8 +142,52 @@ 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",
|
||||
)
|
||||
|
||||
+86
-15
@@ -1,47 +1,118 @@
|
||||
# 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
|
||||
# Docker Compose for production deployment
|
||||
#
|
||||
# Local testing:
|
||||
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:shardok_server_load
|
||||
# Run: docker compose -f docker-compose.prod.yml up
|
||||
#
|
||||
# Production deployment:
|
||||
# Run: docker compose -f docker-compose.prod.yml up -d
|
||||
|
||||
services:
|
||||
eagle:
|
||||
image: eagle0/eagle-server:latest
|
||||
image: ${EAGLE_IMAGE:-registry.digitalocean.com/eagle0/eagle-server:latest}
|
||||
container_name: eagle-server
|
||||
command:
|
||||
- "--gpt-model-name"
|
||||
- "${GPT_MODEL_NAME:-gpt-5.1}"
|
||||
- "--shardok-interface-remote-address"
|
||||
- "shardok:40042"
|
||||
ports:
|
||||
- "40032:40032"
|
||||
environment:
|
||||
# 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"
|
||||
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
|
||||
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
|
||||
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
|
||||
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
|
||||
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
|
||||
volumes:
|
||||
# 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", "nc", "-z", "localhost", "40032"]
|
||||
test: ["CMD-SHELL", "nc -z localhost 40032 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
shardok:
|
||||
image: eagle0/shardok-server:latest
|
||||
image: ${SHARDOK_IMAGE:-registry.digitalocean.com/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", "nc", "-z", "localhost", "40042"]
|
||||
test: ["CMD-SHELL", "nc -z localhost 40042 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: nginx
|
||||
ports:
|
||||
- "443:443"
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./certbot/conf:/etc/letsencrypt:ro
|
||||
- ./certbot/www:/var/www/certbot:ro
|
||||
- ./auth:/etc/nginx/auth:ro
|
||||
depends_on:
|
||||
- eagle
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
|
||||
admin:
|
||||
image: ${ADMIN_IMAGE:-registry.digitalocean.com/eagle0/admin-server:latest}
|
||||
container_name: admin-server
|
||||
command:
|
||||
- "--eagle-addr"
|
||||
- "eagle:40032"
|
||||
- "--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;'"
|
||||
|
||||
+39
-7
@@ -197,9 +197,9 @@ Change APIs to vend Scala `GameState` and `ActionResultT` instead of proto versi
|
||||
|
||||
### Remaining Proto Usage in Actions
|
||||
|
||||
**Progress: 46 of 52 action files (88%) are fully protoless.**
|
||||
**Progress: 47 of 52 action files (90%) are fully protoless.**
|
||||
|
||||
The following 6 actions still have proto usage:
|
||||
The following 5 actions still have proto usage:
|
||||
|
||||
| Action | Proto Usages | Blocker | Effort |
|
||||
|--------|--------------|---------|--------|
|
||||
@@ -208,7 +208,8 @@ The following 6 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 |
|
||||
| `NewRoundAction` | 1 | `ChronicleEventGenerator` returns proto | Medium |
|
||||
|
||||
**Note:** `NewRoundAction` is now fully protoless after converting `ChronicleEventGenerator` to return Scala `ChronicleEvent` types directly.
|
||||
|
||||
**Deleted Dead Code:**
|
||||
- `UnaffiliatedHeroMovedAction` - Was never called; `PerformUnaffiliatedHeroesAction.heroMovedResult` constructs `ActionResultC` directly
|
||||
@@ -224,16 +225,47 @@ The following 6 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** | **~3000** | | |
|
||||
| **Total Remaining** | **~2600** | | |
|
||||
|
||||
**Completed:**
|
||||
- `ChronicleEventGenerator` converted to return Scala `ChronicleEvent` types directly
|
||||
|
||||
### CommandChoiceHelpers Migration Status
|
||||
|
||||
Several command selectors have already been converted to use Scala types:
|
||||
|
||||
| File | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| `AttackCommandChooser.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
|
||||
| `AlmsCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
|
||||
| `FoodConsumptionUtils.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `RoundPhase` |
|
||||
| `MarchSuppliesHelpers.scala` | ✅ **Protoless** | Uses `BattalionT` |
|
||||
| `CombatUnitSelector.scala` | ✅ **Protoless** | Uses `HeroT`, `BattalionT`, `BattalionType` |
|
||||
| `ExpandCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `FactionT` |
|
||||
| `ImproveCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `HeroT` |
|
||||
| `OrganizeCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `BattalionT`, `BattalionType` |
|
||||
| `RansomOfferHelpers.scala` | ✅ **Protoless** | Uses Scala `GameState`, `FactionT` |
|
||||
| `CommandChoiceHelpers.scala` | ❌ Proto | Main entry point, converts to Scala when calling converted selectors |
|
||||
| `ProvinceGoldSurplusCalculator.scala` | **Partial** | Has both Scala and proto overloads |
|
||||
| Other selectors | ❌ Proto | Various proto dependencies |
|
||||
|
||||
**Pattern**: `CommandChoiceHelpers` currently uses `GameStateConverter.fromProto(gameState)` when calling already-converted selectors like `AlmsCommandSelector` and `AttackCommandChooser`. This allows incremental migration.
|
||||
|
||||
**Next Steps**:
|
||||
1. ~~Convert `ExpandCommandSelector` to Scala types~~ ✅ Done
|
||||
2. ~~Convert `ImproveCommandSelector` to Scala types~~ ✅ Done
|
||||
3. ~~Convert `OrganizeCommandSelector` to Scala types~~ ✅ Done (PR #4812)
|
||||
4. ~~Convert `RansomOfferHelpers` to Scala types~~ ✅ Done (PR #4821)
|
||||
5. Convert remaining selectors one at a time
|
||||
6. Update `CommandChoiceHelpers` to accept Scala `GameState` once all selectors are converted
|
||||
|
||||
### Progress Summary
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Action files fully protoless | 46 / 52 (88%) |
|
||||
| Proto usages in remaining actions | 33 total |
|
||||
| Action files fully protoless | 47 / 52 (90%) |
|
||||
| Proto usages in remaining actions | 32 total |
|
||||
| Biggest blocker | `ResolveBattleAction` (24 usages) |
|
||||
| Second biggest blocker | `CommandChoiceHelpers` (blocks 4 actions) |
|
||||
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
# 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.
|
||||
@@ -348,6 +348,68 @@ 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
|
||||
|
||||
+54
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL",
|
||||
"__INPUT_ARTIFACTS_HASH": 289080209,
|
||||
"__RESOLVED_ARTIFACTS_HASH": -131178107,
|
||||
"__INPUT_ARTIFACTS_HASH": -1064460283,
|
||||
"__RESOLVED_ARTIFACTS_HASH": -1574144850,
|
||||
"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,6 +48,12 @@
|
||||
},
|
||||
"version": "2.12.7"
|
||||
},
|
||||
"com.github.stephenc.jcip:jcip-annotations": {
|
||||
"shasums": {
|
||||
"jar": "4fccff8382aafc589962c4edb262f6aa595e34f1e11e61057d1c6a96e8fc7323"
|
||||
},
|
||||
"version": "1.0-1"
|
||||
},
|
||||
"com.google.android:annotations": {
|
||||
"shasums": {
|
||||
"jar": "ba734e1e84c09d615af6a09d33034b4f0442f8772dec120efb376d86a565ae15"
|
||||
@@ -156,6 +162,12 @@
|
||||
},
|
||||
"version": "1.4.2"
|
||||
},
|
||||
"com.nimbusds:nimbus-jose-jwt": {
|
||||
"shasums": {
|
||||
"jar": "12ae4a3a260095d7aeba2adea7ae396e8b9570db8b7b409e09a824c219cc0444"
|
||||
},
|
||||
"version": "9.37.3"
|
||||
},
|
||||
"com.squareup.okhttp3:okhttp": {
|
||||
"shasums": {
|
||||
"jar": "b1050081b14bb7a3a7e55a4d3ef01b5dcfabc453b4573a4fc019767191d5f4e0"
|
||||
@@ -810,6 +822,9 @@
|
||||
"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"
|
||||
@@ -1355,6 +1370,9 @@
|
||||
"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"
|
||||
],
|
||||
@@ -1500,6 +1518,38 @@
|
||||
"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",
|
||||
@@ -2337,6 +2387,7 @@
|
||||
"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",
|
||||
@@ -2355,6 +2406,7 @@
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/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,6 +26,13 @@ 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));
|
||||
|
||||
@@ -58,10 +65,14 @@ 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,6 +7,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <bit>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
|
||||
#define ITERABLE_BITSET_INDEX_CHECKS false
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "MapUtils.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <stdexcept>
|
||||
|
||||
static inline std::string StringForKey(
|
||||
const std::unordered_map<std::string, std::string>& map,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#ifndef byte_vector_h
|
||||
#define byte_vector_h
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include "TranspositionTable.hpp"
|
||||
|
||||
#include <cinttypes>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
@@ -101,10 +102,10 @@ void TranspositionTable::clear() {
|
||||
|
||||
void TranspositionTable::printStats() const {
|
||||
printf("TranspositionTable Stats:\n");
|
||||
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(" 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(" Table size: %zu entries (%.1f MB)\n",
|
||||
TABLE_SIZE,
|
||||
(TABLE_SIZE * sizeof(TTEntry)) / (1024.0 * 1024.0));
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
#include "AiBattleConfig.hpp"
|
||||
#include "AiBattleSimulator.hpp"
|
||||
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
|
||||
|
||||
using shardok::ai_battle_simulator::AiBattleConfigLoader;
|
||||
using shardok::ai_battle_simulator::AiBattleSimulator;
|
||||
@@ -109,10 +108,6 @@ int main(int argc, char* argv[]) {
|
||||
// Set exec path for FilesystemUtils
|
||||
FilesystemUtils::SetExecPath(argv[0]);
|
||||
|
||||
// Set cache directory for ActionPointDistances
|
||||
shardok::FixedActionPointDistances::SetCacheDirectory(
|
||||
FilesystemUtils::CacheFilesDirectory() + "apdCache/");
|
||||
|
||||
try {
|
||||
if (argc < 2) {
|
||||
PrintUsage(argv[0]);
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/AIClientFactory.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/GamePhaseRunner.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
|
||||
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
|
||||
|
||||
using namespace shardok;
|
||||
@@ -84,10 +83,6 @@ int main(int argc, char* argv[]) {
|
||||
// Set exec path so FilesystemUtils can find resource files
|
||||
FilesystemUtils::SetExecPath(argv[0]);
|
||||
|
||||
// Set cache directory for ActionPointDistances
|
||||
FixedActionPointDistances::SetCacheDirectory(
|
||||
FilesystemUtils::CacheFilesDirectory() + "apdCache/");
|
||||
|
||||
try {
|
||||
std::cout << "Shardok AI Performance Runner\n";
|
||||
std::cout << "==============================\n";
|
||||
|
||||
+3
-21
@@ -29,8 +29,6 @@ thread_local struct {
|
||||
int localMisses = 0;
|
||||
int sharedAccesses = 0;
|
||||
int evictionEvents = 0;
|
||||
int apdLoadedFromFile = 0;
|
||||
int apdGeneratedFresh = 0;
|
||||
std::chrono::steady_clock::time_point lastReportTime = std::chrono::steady_clock::now();
|
||||
} cacheStats;
|
||||
|
||||
@@ -40,16 +38,13 @@ static void MaybePrintCacheStats() {
|
||||
if (std::chrono::duration_cast<std::chrono::seconds>(now - cacheStats.lastReportTime).count() >=
|
||||
CACHE_STATS_FREQUENCY_SECONDS_) {
|
||||
printf("Thread cache stats: %d persistent hits, %d persistent misses, %d local hits, "
|
||||
"%d local misses, %d shared accesses, %d eviction events, "
|
||||
"%d APD loaded from file, %d APD generated fresh\n",
|
||||
"%d local misses, %d shared accesses, %d eviction events\n",
|
||||
cacheStats.persistentHits,
|
||||
cacheStats.persistentMisses,
|
||||
cacheStats.localHits,
|
||||
cacheStats.localMisses,
|
||||
cacheStats.sharedAccesses,
|
||||
cacheStats.evictionEvents,
|
||||
cacheStats.apdLoadedFromFile,
|
||||
cacheStats.apdGeneratedFresh);
|
||||
cacheStats.evictionEvents);
|
||||
cacheStats.lastReportTime = now;
|
||||
}
|
||||
}
|
||||
@@ -195,25 +190,12 @@ auto ActionPointDistancesCache::GetRaw(
|
||||
}
|
||||
|
||||
// Create new pathfinding result using factory method
|
||||
auto creationResult = FixedActionPointDistances::Create(
|
||||
auto result = FixedActionPointDistances::Create(
|
||||
mapToUse,
|
||||
mapId.terrainTypesId,
|
||||
mapId.modifierId,
|
||||
battalionType,
|
||||
includeBravingWater,
|
||||
braveWaterActionPointCost);
|
||||
|
||||
#if CACHE_STATS_LOGGING_
|
||||
// Track whether this was loaded from file or generated fresh
|
||||
if (creationResult.loadedFromFile) {
|
||||
cacheStats.apdLoadedFromFile++;
|
||||
} else {
|
||||
cacheStats.apdGeneratedFresh++;
|
||||
}
|
||||
#endif
|
||||
|
||||
auto result = creationResult.apd;
|
||||
|
||||
// Store in shared cache
|
||||
sharedDistances.lazy_emplace_l(
|
||||
cacheKey,
|
||||
|
||||
+32
-86
@@ -22,110 +22,56 @@ static const int ASYNC_COUNT = []() {
|
||||
|
||||
namespace shardok {
|
||||
|
||||
void FixedActionPointDistances::SetCacheDirectory(const string& newDir) {
|
||||
cacheDirectory = newDir;
|
||||
FilesystemUtils::MakeDirectoryIfNecessary(cacheDirectory);
|
||||
}
|
||||
|
||||
static thread_local byte_vector _scratch;
|
||||
|
||||
FixedActionPointDistances::FixedActionPointDistances(const HexMap* /*map*/, int columnCount)
|
||||
: ActionPointDistances(columnCount) {}
|
||||
|
||||
auto FixedActionPointDistances::Create(
|
||||
const HexMap* map,
|
||||
int64_t terrainTypesHash,
|
||||
int64_t modifierHash,
|
||||
const BattalionTypeSPtr& battalionType,
|
||||
bool includeBravingWater,
|
||||
int braveWaterActionPointCost) -> CreationResult {
|
||||
int braveWaterActionPointCost) -> std::shared_ptr<FixedActionPointDistances> {
|
||||
// Create the object using private constructor
|
||||
auto apd = std::shared_ptr<FixedActionPointDistances>(
|
||||
new FixedActionPointDistances(map, map->column_count()));
|
||||
|
||||
CreationResult result;
|
||||
result.apd = apd;
|
||||
result.loadedFromFile = false;
|
||||
|
||||
string path = "";
|
||||
|
||||
if (!cacheDirectory.empty()) {
|
||||
std::stringstream stream;
|
||||
stream << cacheDirectory;
|
||||
stream << std::hex << terrainTypesHash << '/';
|
||||
|
||||
if (auto directoryPath = stream.str(); !FilesystemUtils::FileExistsAtPath(directoryPath)) {
|
||||
FilesystemUtils::MakeDirectoryIfNecessary(stream.str());
|
||||
}
|
||||
|
||||
stream << std::hex << modifierHash;
|
||||
stream << " " << battalionType->typeId;
|
||||
if (includeBravingWater) { stream << " " << braveWaterActionPointCost; }
|
||||
stream << ".apd";
|
||||
path = stream.str();
|
||||
}
|
||||
|
||||
const int indexCount = map->row_count() * map->column_count();
|
||||
|
||||
if (!path.empty() && FilesystemUtils::FileExistsAtPath(path)) {
|
||||
apd->distances.resize(indexCount);
|
||||
// load from file
|
||||
const auto& bytes = _scratch.ReplaceWithPath(path);
|
||||
const auto* ptr = reinterpret_cast<const DIST_T*>(bytes.data());
|
||||
vector<std::future<vector<vector<DIST_T>>>> futures(indexCount);
|
||||
|
||||
for (int fromIndex = 0; fromIndex < indexCount; fromIndex++) {
|
||||
apd->distances[fromIndex].insert(
|
||||
apd->distances[fromIndex].end(),
|
||||
&(ptr[0]),
|
||||
&(ptr[indexCount]));
|
||||
ptr += indexCount;
|
||||
}
|
||||
result.loadedFromFile = true;
|
||||
} else {
|
||||
_scratch.reserve(indexCount * indexCount * sizeof(DIST_T));
|
||||
auto braveWaterPossibleCoords =
|
||||
includeBravingWater ? apd->BraveWaterPossibleCoords(map) : nullptr;
|
||||
|
||||
vector<std::future<vector<vector<DIST_T>>>> futures(indexCount);
|
||||
int chunkSize = (indexCount + ASYNC_COUNT - 1) / ASYNC_COUNT;
|
||||
// Break into chunks for async
|
||||
for (int chunkIdx = 0; chunkIdx < ASYNC_COUNT; chunkIdx++) {
|
||||
futures[chunkIdx] = std::async(std::launch::async, [=]() -> vector<vector<DIST_T>> {
|
||||
vector<vector<DIST_T>> chunkVec;
|
||||
chunkVec.reserve(chunkSize);
|
||||
const int chunkStartIndex = chunkIdx * chunkSize;
|
||||
|
||||
auto braveWaterPossibleCoords =
|
||||
includeBravingWater ? apd->BraveWaterPossibleCoords(map) : nullptr;
|
||||
|
||||
int chunkSize = (indexCount + ASYNC_COUNT - 1) / ASYNC_COUNT;
|
||||
// Break into chunks for async
|
||||
for (int chunkIdx = 0; chunkIdx < ASYNC_COUNT; chunkIdx++) {
|
||||
futures[chunkIdx] = std::async(std::launch::async, [=]() -> vector<vector<DIST_T>> {
|
||||
vector<vector<DIST_T>> chunkVec;
|
||||
chunkVec.reserve(chunkSize);
|
||||
const int chunkStartIndex = chunkIdx * chunkSize;
|
||||
|
||||
for (int i = 0; i < chunkSize; i++) {
|
||||
const auto fromIndex = chunkStartIndex + i;
|
||||
if (fromIndex >= indexCount) { continue; }
|
||||
chunkVec.push_back(ActionPointDistances::GenerateDistances(
|
||||
fromIndex,
|
||||
map,
|
||||
includeBravingWater,
|
||||
braveWaterActionPointCost,
|
||||
battalionType,
|
||||
braveWaterPossibleCoords));
|
||||
}
|
||||
return chunkVec;
|
||||
});
|
||||
}
|
||||
|
||||
apd->distances.reserve(indexCount);
|
||||
_scratch.clear();
|
||||
_scratch.reserve(indexCount * indexCount * sizeof(DIST_T));
|
||||
|
||||
for (int chunkIdx = 0; chunkIdx < ASYNC_COUNT; chunkIdx++) {
|
||||
auto resultsVec = futures[chunkIdx].get();
|
||||
apd->distances.insert(apd->distances.end(), resultsVec.begin(), resultsVec.end());
|
||||
for (const auto& r : resultsVec) { _scratch.append(r); }
|
||||
}
|
||||
|
||||
if (!path.empty()) { FilesystemUtils::AtomicallySaveToPath(path, _scratch); }
|
||||
for (int i = 0; i < chunkSize; i++) {
|
||||
const auto fromIndex = chunkStartIndex + i;
|
||||
if (fromIndex >= indexCount) { continue; }
|
||||
chunkVec.push_back(ActionPointDistances::GenerateDistances(
|
||||
fromIndex,
|
||||
map,
|
||||
includeBravingWater,
|
||||
braveWaterActionPointCost,
|
||||
battalionType,
|
||||
braveWaterPossibleCoords));
|
||||
}
|
||||
return chunkVec;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
apd->distances.reserve(indexCount);
|
||||
|
||||
for (int chunkIdx = 0; chunkIdx < ASYNC_COUNT; chunkIdx++) {
|
||||
auto resultsVec = futures[chunkIdx].get();
|
||||
apd->distances.insert(apd->distances.end(), resultsVec.begin(), resultsVec.end());
|
||||
}
|
||||
|
||||
return apd;
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
+2
-16
@@ -17,31 +17,19 @@ using std::vector;
|
||||
using BattalionTypeSPtr = std::shared_ptr<const BattalionType>;
|
||||
|
||||
class FixedActionPointDistances final : public ActionPointDistances {
|
||||
public:
|
||||
struct CreationResult {
|
||||
std::shared_ptr<FixedActionPointDistances> apd;
|
||||
bool loadedFromFile;
|
||||
};
|
||||
|
||||
private:
|
||||
vector<vector<DIST_T>> distances;
|
||||
|
||||
inline static string cacheDirectory = "";
|
||||
|
||||
// Private constructor - use Create factory method instead
|
||||
explicit FixedActionPointDistances(const HexMap *map, int columnCount);
|
||||
|
||||
public:
|
||||
static void SetCacheDirectory(const string &newDir);
|
||||
|
||||
// Factory method to create FixedActionPointDistances with metadata
|
||||
// Factory method to create FixedActionPointDistances
|
||||
static auto Create(
|
||||
const HexMap *map,
|
||||
int64_t terrainTypesHash,
|
||||
int64_t modifierHash,
|
||||
const BattalionTypeSPtr &battalionType,
|
||||
bool includeBravingWater,
|
||||
int braveWaterActionPointCost = -1) -> CreationResult;
|
||||
int braveWaterActionPointCost = -1) -> std::shared_ptr<FixedActionPointDistances>;
|
||||
|
||||
~FixedActionPointDistances() override = default;
|
||||
|
||||
@@ -52,8 +40,6 @@ public:
|
||||
[[nodiscard]] auto Distance(const Coords &from, const Coords &to) const -> DIST_T override {
|
||||
return Distance(ToIndex(from), ToIndex(to));
|
||||
}
|
||||
|
||||
friend struct CreationResult;
|
||||
};
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include "ServerConfiguration.hpp"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
@@ -30,9 +31,16 @@ 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, "localhost"},
|
||||
{ServerConfiguration::kEagleInterfaceGrpcAddress, "localhost"}};
|
||||
{ServerConfiguration::kShardokGrpcAddress, shardokAddr ? shardokAddr : "localhost"},
|
||||
{ServerConfiguration::kEagleInterfaceGrpcAddress,
|
||||
eagleInterfaceAddr ? eagleInterfaceAddr : "localhost:40042"}};
|
||||
}
|
||||
|
||||
ServerConfiguration::ServerConfiguration(const string& filePath) {
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
#include "ServerConfiguration.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/controller/ShardokGameController.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/library/settings_loader/SettingsLoader.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/util/MapLoader.hpp"
|
||||
#include "src/main/flatbuffer/net/eagle0/shardok/storage/unit.hpp"
|
||||
@@ -45,9 +44,6 @@ ShardokGamesManager::ShardokGamesManager(const std::vector<std::string> &extraSe
|
||||
std::cerr << "NOT SETTING" << std::endl;
|
||||
// setter.SetRaw(key, value);
|
||||
}
|
||||
|
||||
FixedActionPointDistances::SetCacheDirectory(
|
||||
FilesystemUtils::CacheFilesDirectory() + "apdCache/");
|
||||
}
|
||||
|
||||
auto ShardokGamesManager::GetController(const GameId &gameId)
|
||||
|
||||
+97
-6
@@ -77,11 +77,14 @@ public class AuthInterceptor : Interceptor {
|
||||
};
|
||||
|
||||
public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
public TMP_InputField urlField;
|
||||
public TMP_Dropdown environmentDropdown;
|
||||
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;
|
||||
@@ -112,11 +115,22 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
|
||||
public ClientPregeneratedText clientPregeneratedText;
|
||||
|
||||
const String DefaultUrl = "eagle0.net";
|
||||
const String URLKey = "urlKey";
|
||||
const String BaseDomain = "eagle0.net";
|
||||
const String EnvironmentKey = "environmentKey";
|
||||
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);
|
||||
}
|
||||
@@ -133,7 +147,11 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
resolutionDropdown.AddOptions(resolutions.Select(r => $"{r.width} x {r.height}").ToList());
|
||||
resolutionDropdown.value = resolutions.ToList().IndexOf(currentResolution);
|
||||
|
||||
urlField.text = PlayerPrefs.GetString(URLKey, DefaultUrl);
|
||||
// Set up environment dropdown
|
||||
environmentDropdown.ClearOptions();
|
||||
environmentDropdown.AddOptions(EnvironmentDisplayNames);
|
||||
environmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
|
||||
|
||||
nameField.text = PlayerPrefs.GetString(NameKey, "sample_name");
|
||||
passwordField.text = PlayerPrefs.GetString(PasswordKey, "sample_password");
|
||||
|
||||
@@ -147,6 +165,78 @@ 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"); }
|
||||
@@ -267,7 +357,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
}
|
||||
|
||||
private void _createConnection() {
|
||||
PlayerPrefs.SetString(URLKey, urlField.text);
|
||||
PlayerPrefs.SetInt(EnvironmentKey, environmentDropdown.value);
|
||||
PlayerPrefs.SetString(NameKey, nameField.text);
|
||||
PlayerPrefs.SetString(PasswordKey, passwordField.text);
|
||||
|
||||
@@ -280,7 +370,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
// Initialize cancellation token for thread management
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
|
||||
eagleConnection = new EagleConnection(nameField.text, passwordField.text, urlField.text);
|
||||
string url = GetUrlFromEnvironment();
|
||||
eagleConnection = new EagleConnection(nameField.text, passwordField.text, url);
|
||||
_persistentClientConnection = new PersistentClientConnection(
|
||||
eagleConnection.EagleGrpcClient,
|
||||
eagleConnection.credentials,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
#endif
|
||||
using UnityEngine;
|
||||
|
||||
public class ConnectionKiller : MonoBehaviour {
|
||||
@@ -10,6 +12,10 @@ 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,20 +311,36 @@ namespace eagle {
|
||||
// Store server-reported game status for UI
|
||||
if (updateItem.ActionResultResponse.ServerGameStatus != null) {
|
||||
ServerStatus = updateItem.ActionResultResponse.ServerGameStatus;
|
||||
_connectionLogger.LogLine(
|
||||
$"[UPDATE] ServerGameStatus updated: {ServerStatus.Status}");
|
||||
}
|
||||
|
||||
// Note: _lastUnfilteredResultCount is updated on the gRPC thread in
|
||||
// UpdateResultCounts() before enqueueing. We don't update it here to avoid
|
||||
// race conditions where a backlogged MainQueue update overwrites a newer count.
|
||||
|
||||
if (updateItem.ActionResultResponse.ActionResultViews.Any() ||
|
||||
updateItem.ActionResultResponse.AvailableCommands == null ||
|
||||
updateItem.ActionResultResponse.AvailableCommands.Token !=
|
||||
_currentModel.CommandToken) {
|
||||
var hasResults = updateItem.ActionResultResponse.ActionResultViews.Any();
|
||||
var hasCommands = updateItem.ActionResultResponse.AvailableCommands != null;
|
||||
var incomingToken =
|
||||
hasCommands ? updateItem.ActionResultResponse.AvailableCommands.Token
|
||||
: -1;
|
||||
var tokenMatches = hasCommands && incomingToken == _currentModel.CommandToken;
|
||||
|
||||
_connectionLogger.LogLine(
|
||||
$"[UPDATE] hasResults={hasResults}, hasCommands={hasCommands}, " +
|
||||
$"incomingToken={incomingToken}, currentToken={_currentModel.CommandTokenString}, " +
|
||||
$"tokenMatches={tokenMatches}");
|
||||
|
||||
if (hasResults || !hasCommands || !tokenMatches) {
|
||||
HandleUpdates(updateItem.ActionResultResponse.ActionResultViews.ToList());
|
||||
HandleAvailableCommands(updateItem.ActionResultResponse.AvailableCommands);
|
||||
|
||||
if (UpdateAction != null) UpdateAction.Invoke(_currentModel);
|
||||
_connectionLogger.LogLine(
|
||||
"[UPDATE] Processed update and invoked UpdateAction");
|
||||
} else {
|
||||
_connectionLogger.LogLine(
|
||||
"[UPDATE] SKIPPED - no results, has commands, token matches");
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -405,9 +421,14 @@ namespace eagle {
|
||||
public void UpdateResultCounts(GameUpdate update) {
|
||||
switch (update.GameUpdateDetailsCase) {
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
|
||||
var newCount = update.ActionResultResponse.UnfilteredResultCountAfter;
|
||||
lock (_resultCountLock) {
|
||||
_lastUnfilteredResultCount =
|
||||
update.ActionResultResponse.UnfilteredResultCountAfter;
|
||||
var oldCount = _lastUnfilteredResultCount;
|
||||
_lastUnfilteredResultCount = newCount;
|
||||
if (newCount != oldCount) {
|
||||
_connectionLogger.LogLine(
|
||||
$"[RESULT_COUNT] Updated count {oldCount} -> {newCount}");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
+2
-1
@@ -57,7 +57,8 @@ namespace eagle.Notifications {
|
||||
{ BreakAllianceAcceptedDetails, BreakAllianceAcceptedNotificationGenerator.Generator },
|
||||
{ VassalExiledDetails, VassalExiledDetailsNotificationGenerator.Generator },
|
||||
{ WithdrewForTruce, WithdrewForTruceDetailsNotificationGenerator.Generator },
|
||||
{ ProfessionGainedDetails, ProfessionGainedDetailsNotificationGenerator.Generator }
|
||||
{ ProfessionGainedDetails, ProfessionGainedDetailsNotificationGenerator.Generator },
|
||||
{ NewFactionHeadDetails, NewFactionHeadDetailsNotificationGenerator.Generator }
|
||||
};
|
||||
|
||||
public static IEnumerable<Notification> GenerateNotifications(
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e9afdd2068b294a29993f07b379eb9c3
|
||||
+184
-87
@@ -44,6 +44,12 @@ namespace eagle {
|
||||
|
||||
private readonly Logger _remoteEagleClientLogger = Logger.GetLogger("ConnectionLogger");
|
||||
private readonly Logger _timingsLogger = Logger.GetLogger("ConnectionLogger");
|
||||
|
||||
/// <summary>Timestamped log for connection flow tracing.</summary>
|
||||
private void LogFlow(string message) {
|
||||
var ts = DateTime.UtcNow.ToString("HH:mm:ss.fff");
|
||||
_remoteEagleClientLogger.LogLine($"[FLOW {ts}] {message}");
|
||||
}
|
||||
private volatile bool _isConnecting = false;
|
||||
private Timer _idleCheckTimer = null;
|
||||
private Timer _heartbeatTimer = null;
|
||||
@@ -122,6 +128,7 @@ namespace eagle {
|
||||
_currentState = ConnectionState.Reconnecting;
|
||||
NextReconnectAttempt = DateTime.UtcNow.AddSeconds(backoffSeconds);
|
||||
|
||||
LogFlow($"SCHEDULE_RECONNECT reason={reason} backoff={backoffSeconds:F1}s attempt={_consecutiveFailures}");
|
||||
LogConnectionEvent(
|
||||
"schedule_reconnect",
|
||||
$"{reason}, backoff={backoffSeconds:F1}s, attempt={_consecutiveFailures}");
|
||||
@@ -194,16 +201,14 @@ namespace eagle {
|
||||
var ackTcs = new TaskCompletionSource<SubscriptionAck>();
|
||||
lock (_pendingSubscriptionAcks) { _pendingSubscriptionAcks[gameId] = ackTcs; }
|
||||
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[SUBSCRIBE] Sending StreamGameRequest for game {gameId}, " +
|
||||
$"unfilteredCount={subscriber.LastUnfilteredResultCount}");
|
||||
var shardokCount = shardokStatuses.Count();
|
||||
LogFlow($"SUBSCRIBE game={gameId} unfilteredCount={subscriber.LastUnfilteredResultCount} shardokGames={shardokCount}");
|
||||
|
||||
var sendSuccess = await DoWithStreamingCall(async (sc) => {
|
||||
await sc.RequestStream.WriteAsync(request);
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[SUBSCRIBE] StreamGameRequest sent successfully for game {gameId}");
|
||||
return true;
|
||||
});
|
||||
await sc.RequestStream.WriteAsync(request).ConfigureAwait(false);
|
||||
LogFlow($"SUBSCRIBE sent for game={gameId}");
|
||||
return true;
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (!sendSuccess) {
|
||||
// Write failed - clean up and return
|
||||
@@ -217,7 +222,7 @@ namespace eagle {
|
||||
try {
|
||||
var ackTask = ackTcs.Task;
|
||||
var timeoutTask = Task.Delay(SubscriptionAckTimeoutMs, timeoutCts.Token);
|
||||
var completedTask = await Task.WhenAny(ackTask, timeoutTask);
|
||||
var completedTask = await Task.WhenAny(ackTask, timeoutTask).ConfigureAwait(false);
|
||||
|
||||
if (completedTask == timeoutTask) {
|
||||
// Timeout waiting for ack
|
||||
@@ -231,24 +236,15 @@ namespace eagle {
|
||||
// Cancel the timeout task since ack was received
|
||||
timeoutCts.Cancel();
|
||||
|
||||
var ack = await ackTask;
|
||||
var ack = await ackTask.ConfigureAwait(false);
|
||||
if (ack.Success) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[SUBSCRIBE] Subscription confirmed for game {gameId}, " +
|
||||
$"confirmedResultCount={ack.ConfirmedResultCount}");
|
||||
|
||||
// Note: Shardok resync flags are cleared in EagleGameModel.HandleOneGameUpdate
|
||||
// AFTER updates are actually received, not here. This ensures that if the
|
||||
// connection drops between acknowledgment and update delivery, the resync
|
||||
// will be requested again on the next reconnect.
|
||||
|
||||
LogFlow($"SUBSCRIBE_ACK game={gameId} success=true confirmedCount={ack.ConfirmedResultCount}");
|
||||
return true;
|
||||
} else {
|
||||
LogFlow($"SUBSCRIBE_ACK game={gameId} success=false error={ack.ErrorMessage}");
|
||||
LogConnectionEvent(
|
||||
"subscribe_ack_failed",
|
||||
$"game={gameId}, error={ack.ErrorMessage}");
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[SUBSCRIBE] Server rejected subscription for game {gameId}: {ack.ErrorMessage}");
|
||||
return false;
|
||||
}
|
||||
} catch (OperationCanceledException) {
|
||||
@@ -266,9 +262,11 @@ namespace eagle {
|
||||
}
|
||||
|
||||
public async Task Connect() {
|
||||
LogFlow($"Connect() called, state={_currentState}, failures={_consecutiveFailures}");
|
||||
|
||||
// Prevent concurrent connection attempts
|
||||
if (_isConnecting) {
|
||||
_remoteEagleClientLogger.LogLine($"Connect() skipped - already connecting");
|
||||
LogFlow("Connect() skipped - already connecting");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -286,6 +284,7 @@ namespace eagle {
|
||||
_currentState = _consecutiveFailures > 0 ? ConnectionState.Reconnecting
|
||||
: ConnectionState.Connecting;
|
||||
NextReconnectAttempt = null;
|
||||
LogFlow($"State -> {_currentState}");
|
||||
LogConnectionEvent("connect_attempt");
|
||||
|
||||
// Dispose existing streaming call before creating new one
|
||||
@@ -318,13 +317,19 @@ namespace eagle {
|
||||
|
||||
// Stream subscriptions OUTSIDE lock (can await)
|
||||
// Set state to SubscriptionPending while waiting for acks
|
||||
LogFlow($"Stream created, {subscribersToStream.Count} subscribers to stream");
|
||||
if (subscribersToStream.Any()) {
|
||||
_currentState = ConnectionState.SubscriptionPending;
|
||||
LogFlow($"State -> {_currentState}");
|
||||
}
|
||||
|
||||
bool allSucceeded = true;
|
||||
foreach (var subscriber in subscribersToStream) {
|
||||
if (!await StreamOneGameAsync(subscriber)) { allSucceeded = false; }
|
||||
LogFlow($"Subscribing game {subscriber.GameId}...");
|
||||
if (!await StreamOneGameAsync(subscriber)) {
|
||||
LogFlow($"Subscription FAILED for game {subscriber.GameId}");
|
||||
allSucceeded = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!allSucceeded) {
|
||||
@@ -353,6 +358,7 @@ namespace eagle {
|
||||
_lastSuccessfulConnect = DateTime.UtcNow;
|
||||
_consecutiveFailures = 0; // Reset backoff on successful connection
|
||||
_currentState = ConnectionState.Connected;
|
||||
LogFlow($"State -> {_currentState} (all subscriptions succeeded)");
|
||||
_circuitBreaker.RecordSuccess();
|
||||
LogConnectionEvent("connect_success");
|
||||
|
||||
@@ -470,8 +476,8 @@ namespace eagle {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await PostRequest(nextCommand);
|
||||
// Note: PostRequest is called inside each case, not here
|
||||
// (removed duplicate PostRequest call that was causing double-posting)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,6 +509,9 @@ namespace eagle {
|
||||
}
|
||||
|
||||
public void Dispose() {
|
||||
Thread threadToJoin = null;
|
||||
CancellationTokenSource tokenSourceToDispose = null;
|
||||
|
||||
lock (this) {
|
||||
// Dispose timers first to stop any pending callbacks
|
||||
StopIdleCheckTimer();
|
||||
@@ -525,20 +534,25 @@ namespace eagle {
|
||||
// Clear pending commands
|
||||
_pendingCommands.Clear();
|
||||
|
||||
// Cancel and dispose thread cancellation token
|
||||
// Cancel thread cancellation token (but don't dispose yet)
|
||||
_threadCancellationTokenSource?.Cancel();
|
||||
|
||||
// Give thread a chance to exit gracefully
|
||||
if (_streamingCallThread != null) {
|
||||
if (!_streamingCallThread.Join(TimeSpan.FromSeconds(2))) {
|
||||
Console.WriteLine("Warning: Streaming call thread did not exit gracefully");
|
||||
}
|
||||
_streamingCallThread = null;
|
||||
}
|
||||
|
||||
_threadCancellationTokenSource?.Dispose();
|
||||
// Capture thread and token source references for cleanup outside lock
|
||||
threadToJoin = _streamingCallThread;
|
||||
_streamingCallThread = null;
|
||||
tokenSourceToDispose = _threadCancellationTokenSource;
|
||||
_threadCancellationTokenSource = null;
|
||||
}
|
||||
|
||||
// Join thread OUTSIDE lock to avoid deadlock - the streaming thread
|
||||
// may be trying to acquire lock(this) when we're waiting for it
|
||||
if (threadToJoin != null) {
|
||||
if (!threadToJoin.Join(TimeSpan.FromSeconds(2))) {
|
||||
Console.WriteLine("Warning: Streaming call thread did not exit gracefully");
|
||||
}
|
||||
}
|
||||
|
||||
tokenSourceToDispose?.Dispose();
|
||||
}
|
||||
|
||||
public void SetLobbySubscriber(ILobbySubscriber subscriber) {
|
||||
@@ -562,16 +576,18 @@ namespace eagle {
|
||||
|
||||
public async Task PostError(EagleGameId gameId, string errorMessage, string stackTrace) {
|
||||
await DoWithStreamingCall(async (streamingCall) => {
|
||||
await streamingCall.RequestStream.WriteAsync(new UpdateStreamRequest {
|
||||
ErrorRequest =
|
||||
new ErrorRequest {
|
||||
ErrorMessage = errorMessage,
|
||||
GameId = gameId,
|
||||
StackTrace = stackTrace
|
||||
}
|
||||
});
|
||||
await streamingCall.RequestStream
|
||||
.WriteAsync(new UpdateStreamRequest {
|
||||
ErrorRequest =
|
||||
new ErrorRequest {
|
||||
ErrorMessage = errorMessage,
|
||||
GameId = gameId,
|
||||
StackTrace = stackTrace
|
||||
}
|
||||
})
|
||||
.ConfigureAwait(false);
|
||||
return true;
|
||||
});
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void Unsubscribe(IClientConnectionSubscriber subscriber) {
|
||||
@@ -583,10 +599,11 @@ namespace eagle {
|
||||
lock (this) { _pendingCommands.Add(request); }
|
||||
|
||||
await DoWithStreamingCall(async (streamingCall) => {
|
||||
await streamingCall.RequestStream.WriteAsync(
|
||||
new UpdateStreamRequest { PostCommandRequest = request });
|
||||
await streamingCall.RequestStream
|
||||
.WriteAsync(new UpdateStreamRequest { PostCommandRequest = request })
|
||||
.ConfigureAwait(false);
|
||||
return true;
|
||||
});
|
||||
}).ConfigureAwait(false);
|
||||
lock (this) { _pendingCommands.Remove(request); }
|
||||
} catch (Exception e) { Console.WriteLine("Failed to post command: " + e); }
|
||||
}
|
||||
@@ -667,8 +684,20 @@ namespace eagle {
|
||||
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> call);
|
||||
private async Task<bool> DoWithStreamingCall(WithStreamingCallDelegate action) {
|
||||
try {
|
||||
return await action(_streamingCall);
|
||||
} catch (RpcException e) {
|
||||
var actionTask = action(_streamingCall);
|
||||
var timeoutTask = Task.Delay(WriteTimeoutMs, _cancellationToken);
|
||||
|
||||
var completedTask =
|
||||
await Task.WhenAny(actionTask, timeoutTask).ConfigureAwait(false);
|
||||
|
||||
if (completedTask == timeoutTask) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
"[WRITE] DoWithStreamingCall timed out - connection may be dead");
|
||||
return false;
|
||||
}
|
||||
|
||||
return await actionTask.ConfigureAwait(false);
|
||||
} catch (OperationCanceledException) { return false; } catch (RpcException e) {
|
||||
if (e.StatusCode == StatusCode.Cancelled) {
|
||||
// This is expected when the connection is closed.
|
||||
return false;
|
||||
@@ -678,6 +707,9 @@ namespace eagle {
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout for WriteAsync to prevent ThreadPool exhaustion from blocked writes
|
||||
private const int WriteTimeoutMs = 10000;
|
||||
|
||||
public async Task<bool> SendUpdateStreamRequestAsync(UpdateStreamRequest request) {
|
||||
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> sc = null;
|
||||
lock (this) {
|
||||
@@ -686,8 +718,18 @@ namespace eagle {
|
||||
}
|
||||
|
||||
try {
|
||||
await sc.RequestStream.WriteAsync(request, _cancellationToken);
|
||||
// Use timeout to prevent indefinite blocking on dead connections
|
||||
using var timeoutCts =
|
||||
CancellationTokenSource.CreateLinkedTokenSource(_cancellationToken);
|
||||
timeoutCts.CancelAfter(WriteTimeoutMs);
|
||||
|
||||
await sc.RequestStream.WriteAsync(request, timeoutCts.Token).ConfigureAwait(false);
|
||||
return true;
|
||||
} catch (OperationCanceledException) {
|
||||
// Timeout or cancellation - connection is likely dead
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
"[WRITE] WriteAsync timed out or cancelled - connection may be dead");
|
||||
return false;
|
||||
} catch (RpcException e) {
|
||||
if (e.StatusCode == StatusCode.Cancelled) {
|
||||
// This is expected when the connection is closed.
|
||||
@@ -706,7 +748,7 @@ namespace eagle {
|
||||
private void HandleGameUpdate(GameUpdate gameUpdate, DateTime receivedTime) {
|
||||
switch (gameUpdate.GameUpdateDetailsCase) {
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.ErrorResponse:
|
||||
_remoteEagleClientLogger.LogLine("Got an error response!");
|
||||
LogFlow($"UPDATE ErrorResponse game={gameUpdate.GameId}");
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
lock (this) { _subscribers.Remove(gameUpdate.GameId); }
|
||||
});
|
||||
@@ -714,7 +756,6 @@ namespace eagle {
|
||||
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.StreamingTextResponse:
|
||||
// Handle streaming text directly on gRPC thread - dictionary is thread-safe.
|
||||
// Listeners are notified later via ProcessPendingUpdates() on main thread.
|
||||
var str = gameUpdate.StreamingTextResponse;
|
||||
if (str != null) {
|
||||
ClientTextProvider.Provider.HandleNewStreamingText(
|
||||
@@ -723,15 +764,22 @@ namespace eagle {
|
||||
str.StartingByteCount,
|
||||
str.Completed);
|
||||
}
|
||||
// No MainQueue enqueue needed - ProcessPendingUpdates handles listener
|
||||
// notification
|
||||
break;
|
||||
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
|
||||
// Update result counts IMMEDIATELY on the gRPC thread, before enqueueing.
|
||||
// This ensures reconnects use accurate counts even when MainQueue is blocked
|
||||
// (e.g., when Unity is backgrounded).
|
||||
var arResp = gameUpdate.ActionResultResponse;
|
||||
var arCount = arResp.ActionResultViews?.Count ?? 0;
|
||||
var hasStartingState = gameUpdate.StartingState != null;
|
||||
var hasCommands = arResp.AvailableCommands != null;
|
||||
var cmdToken = hasCommands ? arResp.AvailableCommands.Token : -1;
|
||||
var cmdCount = hasCommands
|
||||
? arResp.AvailableCommands.CommandsByProvince?.Count ?? 0
|
||||
: 0;
|
||||
var status = arResp.ServerGameStatus?.Status.ToString() ?? "null";
|
||||
LogFlow($"UPDATE ActionResult game={gameUpdate.GameId} actionResults={arCount} " +
|
||||
$"startingState={hasStartingState} hasCommands={hasCommands} " +
|
||||
$"token={cmdToken} cmdProvinces={cmdCount} status={status}");
|
||||
|
||||
lock (this) {
|
||||
if (_subscribers.TryGetValue(gameUpdate.GameId, out var sub)) {
|
||||
sub.UpdateResultCounts(gameUpdate);
|
||||
@@ -747,19 +795,38 @@ namespace eagle {
|
||||
_subscribers.Remove(gameUpdate.GameId);
|
||||
}
|
||||
}
|
||||
|
||||
await TryPendingCommands();
|
||||
});
|
||||
break;
|
||||
|
||||
var processTime = (DateTime.UtcNow - receivedTime).TotalMilliseconds;
|
||||
if (processTime > 100.0) {
|
||||
_timingsLogger.LogLine($"PROCESS {processTime} ms");
|
||||
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
|
||||
var shResp = gameUpdate.ShardokActionResultResponse;
|
||||
var shGames = shResp.ShardokGameResponses?.Count ?? 0;
|
||||
LogFlow($"UPDATE ShardokResult game={gameUpdate.GameId} shardokGames={shGames}");
|
||||
|
||||
lock (this) {
|
||||
if (_subscribers.TryGetValue(gameUpdate.GameId, out var sub2)) {
|
||||
sub2.UpdateResultCounts(gameUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
MainQueue.Q.Enqueue(async () => {
|
||||
IClientConnectionSubscriber subscriber;
|
||||
lock (this) {
|
||||
if (_subscribers.TryGetValue(gameUpdate.GameId, out subscriber)) {
|
||||
subscriber.ReceiveGameUpdate(gameUpdate);
|
||||
} else {
|
||||
_subscribers.Remove(gameUpdate.GameId);
|
||||
}
|
||||
}
|
||||
await TryPendingCommands();
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async void HandleStreamingCall() {
|
||||
LogFlow("HandleStreamingCall STARTED");
|
||||
try {
|
||||
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> sc;
|
||||
CancellationToken threadToken;
|
||||
@@ -875,8 +942,10 @@ namespace eagle {
|
||||
waitStartTime = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
"How did we get here? This is not my beautiful wife!");
|
||||
// While loop exited normally (not via exception)
|
||||
var scNull = sc == null;
|
||||
var tokenCancelled = _currentThreadToken.IsCancellationRequested;
|
||||
LogFlow($"HandleStreamingCall ENDED normally: sc_null={scNull} token_cancelled={tokenCancelled}");
|
||||
} catch (RpcException e) {
|
||||
lock (this) {
|
||||
_lastDisconnect = DateTime.UtcNow;
|
||||
@@ -974,7 +1043,13 @@ namespace eagle {
|
||||
AutoReset = true,
|
||||
Interval = 5000 // Check every 5 seconds
|
||||
};
|
||||
_idleCheckTimer.Elapsed += (sender, args) => CheckForIdleTimeout();
|
||||
_idleCheckTimer.Elapsed += (sender, args) => {
|
||||
try {
|
||||
CheckForIdleTimeout();
|
||||
} catch (Exception e) {
|
||||
LogFlow($"IDLE_TIMER_ERROR {e.GetType().Name}: {e.Message}");
|
||||
}
|
||||
};
|
||||
_idleCheckTimer.Enabled = true;
|
||||
}
|
||||
|
||||
@@ -987,10 +1062,16 @@ namespace eagle {
|
||||
}
|
||||
|
||||
private void CheckForIdleTimeout() {
|
||||
if (_cancellationToken.IsCancellationRequested) { return; }
|
||||
|
||||
var idleTime = (DateTime.UtcNow - _lastResponseReceived).TotalSeconds;
|
||||
|
||||
// Log every check when idle > 5s to diagnose timer issues
|
||||
if (idleTime > 5.0) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[IDLE_CHECK] idle_time={idleTime:F1}s, cancelled={_cancellationToken.IsCancellationRequested}");
|
||||
}
|
||||
|
||||
if (_cancellationToken.IsCancellationRequested) { return; }
|
||||
|
||||
// Early warnings at 10s and 20s to help diagnose slow connections
|
||||
if (idleTime > 20.0 && _lastIdleWarningLevel < 2) {
|
||||
_lastIdleWarningLevel = 2;
|
||||
@@ -1022,7 +1103,19 @@ namespace eagle {
|
||||
StopHeartbeatTimer();
|
||||
_heartbeatTimer =
|
||||
new Timer { AutoReset = true, Interval = HeartbeatIntervalSeconds * 1000 };
|
||||
_heartbeatTimer.Elapsed += (sender, args) => Task.Run(() => SendHeartbeat());
|
||||
_heartbeatTimer.Elapsed += (sender, args) => {
|
||||
try {
|
||||
Task.Run(async () => {
|
||||
try {
|
||||
await SendHeartbeat().ConfigureAwait(false);
|
||||
} catch (Exception e) {
|
||||
LogFlow($"HEARTBEAT_ERROR {e.GetType().Name}: {e.Message}");
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
LogFlow($"HEARTBEAT_TIMER_ERROR {e.GetType().Name}: {e.Message}");
|
||||
}
|
||||
};
|
||||
_heartbeatTimer.Enabled = true;
|
||||
}
|
||||
|
||||
@@ -1035,8 +1128,16 @@ namespace eagle {
|
||||
}
|
||||
|
||||
private async Task SendHeartbeat() {
|
||||
if (_cancellationToken.IsCancellationRequested) { return; }
|
||||
if (_currentState != ConnectionState.Connected) { return; }
|
||||
LogFlow($"HEARTBEAT_START state={_currentState}");
|
||||
|
||||
if (_cancellationToken.IsCancellationRequested) {
|
||||
LogFlow("HEARTBEAT_SKIP reason=cancellation_requested");
|
||||
return;
|
||||
}
|
||||
if (_currentState != ConnectionState.Connected) {
|
||||
LogFlow($"HEARTBEAT_SKIP reason=not_connected state={_currentState}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build sync status for all subscribed games
|
||||
var heartbeatRequest = new HeartbeatRequest {
|
||||
@@ -1066,8 +1167,13 @@ namespace eagle {
|
||||
|
||||
var sent = await SendUpdateStreamRequestAsync(request);
|
||||
if (sent) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[HEARTBEAT] Sent heartbeat with {heartbeatRequest.GameSyncStatuses.Count} games");
|
||||
var gameInfo = string.Join(
|
||||
", ",
|
||||
heartbeatRequest.GameSyncStatuses.Select(
|
||||
g => $"{g.GameId}:{g.UnfilteredResultCount}"));
|
||||
LogFlow($"HEARTBEAT_SENT games={heartbeatRequest.GameSyncStatuses.Count} ({gameInfo})");
|
||||
} else {
|
||||
LogFlow("HEARTBEAT_FAILED stream_unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1076,31 +1182,19 @@ namespace eagle {
|
||||
private const double SyncMismatchGracePeriodSeconds = 60.0;
|
||||
|
||||
private void HandleHeartbeatResponse(HeartbeatResponse response) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[HEARTBEAT] Got response, server_timestamp={response.ServerTimestamp}");
|
||||
LogFlow($"HEARTBEAT_RESPONSE server_ts={response.ServerTimestamp}");
|
||||
|
||||
// Check for sync mismatches reported by server
|
||||
bool hasMismatch = false;
|
||||
foreach (var syncResult in response.GameSyncResults) {
|
||||
if (!syncResult.EagleInSync) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[SYNC_MISMATCH] Game {syncResult.GameId}: Eagle out of sync, " +
|
||||
$"server has {syncResult.ServerUnfilteredResultCount} results");
|
||||
LogConnectionEvent(
|
||||
"sync_mismatch_eagle",
|
||||
$"game={syncResult.GameId}, server_count={syncResult.ServerUnfilteredResultCount}");
|
||||
LogFlow($"SYNC_MISMATCH game={syncResult.GameId} server_count={syncResult.ServerUnfilteredResultCount}");
|
||||
hasMismatch = true;
|
||||
}
|
||||
|
||||
foreach (var shardokResult in syncResult.ShardokSyncResults) {
|
||||
if (!shardokResult.InSync) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[SYNC_MISMATCH] Game {syncResult.GameId}, Shardok {shardokResult.ShardokGameId}: " +
|
||||
$"out of sync, server has {shardokResult.ServerFilteredResultCount} results");
|
||||
LogConnectionEvent(
|
||||
"sync_mismatch_shardok",
|
||||
$"game={syncResult.GameId}, shardok={shardokResult.ShardokGameId}, " +
|
||||
$"server_count={shardokResult.ServerFilteredResultCount}");
|
||||
LogFlow($"SYNC_MISMATCH game={syncResult.GameId} shardok={shardokResult.ShardokGameId} server_count={shardokResult.ServerFilteredResultCount}");
|
||||
hasMismatch = true;
|
||||
}
|
||||
}
|
||||
@@ -1126,6 +1220,9 @@ namespace eagle {
|
||||
LogConnectionEvent("sync_mismatch_reconnect", "Triggering reconnect to resync");
|
||||
|
||||
// Schedule reconnect to resync
|
||||
// Stop timers FIRST to prevent them firing during reconnection
|
||||
StopHeartbeatTimer();
|
||||
StopIdleCheckTimer();
|
||||
lock (this) {
|
||||
_streamingCall?.Dispose();
|
||||
_streamingCall = null;
|
||||
|
||||
@@ -28,11 +28,7 @@ public class EagleConnection : IDisposable {
|
||||
_channel = GrpcChannel.ForAddress(
|
||||
"https://" + url,
|
||||
new GrpcChannelOptions {
|
||||
HttpHandler =
|
||||
new YetAnotherHttpHandler {
|
||||
Http2Only = true,
|
||||
Http2KeepAliveInterval = TimeSpan.FromSeconds(KeepAliveSeconds)
|
||||
},
|
||||
HttpHandler = CreateHttpHandler(),
|
||||
DisposeHttpClient = true,
|
||||
LoggerFactory = _loggerFactory,
|
||||
MaxReceiveMessageSize = null
|
||||
@@ -42,6 +38,25 @@ public class EagleConnection : IDisposable {
|
||||
return invoker;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create HTTP handler with timeouts configured for reliable connection management.
|
||||
/// These settings help detect and recover from dead connections, especially on Windows
|
||||
/// where firewalls may silently drop HTTP/2 keep-alive pings.
|
||||
/// </summary>
|
||||
private static YetAnotherHttpHandler CreateHttpHandler() {
|
||||
return new YetAnotherHttpHandler {
|
||||
Http2Only = true,
|
||||
// Send keep-alive pings every 15 seconds
|
||||
Http2KeepAliveInterval = TimeSpan.FromSeconds(KeepAliveSeconds),
|
||||
// Close connection if ping not acknowledged within 5 seconds
|
||||
Http2KeepAliveTimeout = TimeSpan.FromSeconds(5),
|
||||
// Continue pinging even when idle to detect dead connections
|
||||
Http2KeepAliveWhileIdle = true,
|
||||
// Don't wait forever for initial connection
|
||||
ConnectTimeout = TimeSpan.FromSeconds(10)
|
||||
};
|
||||
}
|
||||
|
||||
public EagleConnection(string playerName, string password, string url) {
|
||||
this.playerName = playerName;
|
||||
credentials = new Metadata { { "user", playerName } };
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -511,6 +511,7 @@ 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,12 +6,16 @@ using UnityEngine;
|
||||
namespace common {
|
||||
public sealed class Logger : IDisposable {
|
||||
private readonly StreamWriter _sw;
|
||||
private readonly object _lock = new object();
|
||||
|
||||
private static Dictionary<String, Logger> _loggers = new Dictionary<String, Logger>();
|
||||
private static readonly object _loggersLock = new object();
|
||||
|
||||
public static Logger GetLogger(string name) {
|
||||
if (!_loggers.ContainsKey(name)) { _loggers[name] = new Logger(name); }
|
||||
return _loggers[name];
|
||||
lock (_loggersLock) {
|
||||
if (!_loggers.ContainsKey(name)) { _loggers[name] = new Logger(name); }
|
||||
return _loggers[name];
|
||||
}
|
||||
}
|
||||
|
||||
private string CurrentTimeString() {
|
||||
@@ -29,8 +33,10 @@ namespace common {
|
||||
}
|
||||
|
||||
public void LogLine(string line) {
|
||||
_sw.WriteLine(CurrentTimeString() + " " + line);
|
||||
_sw.Flush();
|
||||
lock (_lock) {
|
||||
_sw.WriteLine(CurrentTimeString() + " " + line);
|
||||
_sw.Flush();
|
||||
}
|
||||
}
|
||||
|
||||
public void Close() { _sw.Close(); }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"com.cysharp.yetanotherhttphandler": "https://github.com/Cysharp/YetAnotherHttpHandler.git?path=src/YetAnotherHttpHandler#1.5.3",
|
||||
"com.cysharp.yetanotherhttphandler": "https://github.com/Cysharp/YetAnotherHttpHandler.git?path=src/YetAnotherHttpHandler#1.11.4",
|
||||
"com.unity.2d.sprite": "1.0.0",
|
||||
"com.unity.2d.tilemap": "1.0.0",
|
||||
"com.unity.ai.navigation": "2.0.9",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"com.cysharp.yetanotherhttphandler": {
|
||||
"version": "https://github.com/Cysharp/YetAnotherHttpHandler.git?path=src/YetAnotherHttpHandler#1.5.3",
|
||||
"version": "https://github.com/Cysharp/YetAnotherHttpHandler.git?path=src/YetAnotherHttpHandler#1.11.4",
|
||||
"depth": 0,
|
||||
"source": "git",
|
||||
"dependencies": {},
|
||||
"hash": "ceb2f24f343c93d66c8a784a283be65b9627efe1"
|
||||
"hash": ""
|
||||
},
|
||||
"com.unity.2d.sprite": {
|
||||
"version": "1.0.0",
|
||||
|
||||
@@ -15,5 +15,16 @@ go_library(
|
||||
go_binary(
|
||||
name = "admin_server",
|
||||
embed = [":admin_server_lib"],
|
||||
pure = "on",
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
# Linux x86_64 target for Docker deployment
|
||||
go_binary(
|
||||
name = "admin_server_linux_amd64",
|
||||
embed = [":admin_server_lib"],
|
||||
goarch = "amd64",
|
||||
goos = "linux",
|
||||
pure = "on",
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
@@ -3,6 +3,22 @@ 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 = [
|
||||
@@ -242,6 +258,7 @@ 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",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//
|
||||
// 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,6 +284,12 @@ 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;
|
||||
@@ -324,5 +330,6 @@ message NotificationDetails {
|
||||
VassalExiledDetails vassal_exiled_details = 8;
|
||||
WithdrewForTruceDetails withdrew_for_truce = 3;
|
||||
ProfessionGainedDetails profession_gained_details = 35;
|
||||
NewFactionHeadDetails new_faction_head_details = 39;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,6 +772,17 @@ 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",
|
||||
@@ -802,6 +813,7 @@ go_proto_library(
|
||||
":shardok_results_proto",
|
||||
":supplies_proto",
|
||||
":unaffiliated_hero_proto",
|
||||
":user_proto",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/common:common_go_proto",
|
||||
|
||||
@@ -53,4 +53,6 @@ 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,6 +72,7 @@ message GeneratedTextRequestDetails {
|
||||
PrisonerReleasedMessage prisoner_released_message = 32;
|
||||
PrisonerExiledMessage prisoner_exiled_message = 33;
|
||||
PrisonerReturnedMessage prisoner_returned_message = 34;
|
||||
NewFactionHeadMessage new_faction_head_message = 35;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,3 +335,13 @@ 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// 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,4 +43,6 @@ 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,7 +7,11 @@ object ApiKeys {
|
||||
private val apiKeyFilePath =
|
||||
"src/main/scala/net/eagle0/common/llm_integration/api_keys.txt"
|
||||
|
||||
// These must have valid values set in api_keys.txt
|
||||
// Environment variable names
|
||||
private val openAIEnvVar = "OPENAI_API_KEY"
|
||||
private val anthropicEnvVar = "ANTHROPIC_API_KEY"
|
||||
|
||||
// File key names
|
||||
private val openAIKeyName = "openai_api_key"
|
||||
private val anthropicKeyName = "anthropic_api_key"
|
||||
|
||||
@@ -23,25 +27,31 @@ object ApiKeys {
|
||||
)
|
||||
}.toMap
|
||||
}.getOrElse {
|
||||
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."
|
||||
)
|
||||
Map.empty // Return empty map if file doesn't exist; we'll check env vars
|
||||
}
|
||||
|
||||
private val dictionary = readDictionary()
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
lazy val openAI: String = getKey(openAIKeyName)
|
||||
lazy val openAI: String = getKey(openAIKeyName, openAIEnvVar)
|
||||
|
||||
lazy val anthropic: String = getKey(anthropicKeyName)
|
||||
lazy val anthropic: String = getKey(anthropicKeyName, anthropicEnvVar)
|
||||
}
|
||||
|
||||
@@ -7,19 +7,26 @@ 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",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -6,8 +6,11 @@ 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.*
|
||||
@@ -92,6 +95,13 @@ 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(
|
||||
@@ -99,8 +109,27 @@ 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)
|
||||
serverBuilder.intercept(new AuthorizationInterceptor(jwtService))
|
||||
|
||||
serverBuilder.build
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ case class AIClient(
|
||||
AlmsCommandSelector
|
||||
.chosenAlmsForSupportWithMinimumCommandForProvince(
|
||||
actingFactionId = fid,
|
||||
gameState = gs,
|
||||
gameState = GameStateConverter.fromProto(gs),
|
||||
availableCommands = acs,
|
||||
minimumFood = 0,
|
||||
provinceId = opac.provinceId
|
||||
|
||||
@@ -74,6 +74,8 @@ 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",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -105,7 +107,7 @@ scala_library(
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
|
||||
],
|
||||
@@ -144,7 +146,7 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:legacy_battalion_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:province_gold_surplus_calculator",
|
||||
],
|
||||
)
|
||||
@@ -180,6 +182,7 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:date_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:incoming_army_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:alms_command_selector",
|
||||
@@ -187,14 +190,15 @@ 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",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -211,6 +215,7 @@ scala_library(
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
|
||||
],
|
||||
)
|
||||
@@ -236,7 +241,10 @@ 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",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -249,17 +257,21 @@ scala_library(
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
|
||||
"//src/main/scala/net/eagle0/common:more_option",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:loyalty_gain_from_feast",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:minimum_loyalty_for_swear_brotherhood",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:hero_gift_command_selector",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:swear_brotherhood_command_selector",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:sworn_brother_chooser",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/hero",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/province",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.internal.province.Province
|
||||
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
|
||||
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
|
||||
import net.eagle0.eagle.library.util.ProvinceDistances
|
||||
import net.eagle0.eagle.library.util.LegacyProvinceDistances
|
||||
|
||||
object FactionLeaderProvinceRanker {
|
||||
private def factionLeaderProvinceOrdering(
|
||||
@@ -23,7 +23,7 @@ object FactionLeaderProvinceRanker {
|
||||
gameState: GameState,
|
||||
fromProvinceId: ProvinceId
|
||||
): Ordering[Province] = Ordering.by((p: Province) =>
|
||||
ProvinceDistances.distanceThroughFriendliesOption(
|
||||
LegacyProvinceDistances.distanceThroughFriendliesOption(
|
||||
fromProvinceId,
|
||||
p.id,
|
||||
factionId,
|
||||
|
||||
@@ -8,6 +8,7 @@ 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 {
|
||||
@@ -22,16 +23,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 = gameState
|
||||
gameState = nativeGameState
|
||||
)
|
||||
}.filter { invitationOption =>
|
||||
ProvinceGoldSurplusCalculator.provinceGoldSurplus(
|
||||
diplomacyAvailableCommand.actingProvinceId,
|
||||
gameState
|
||||
nativeGameState.provinces(diplomacyAvailableCommand.actingProvinceId)
|
||||
) >= invitationOption.goldCost
|
||||
}.map { invitationOption =>
|
||||
InvitationOptionWithChance(
|
||||
@@ -39,7 +40,8 @@ object InvitationCommandSelector {
|
||||
acceptanceChance = ResolveDiplomacyCommandSelector.invitationAcceptanceChance(
|
||||
actingFactionId,
|
||||
invitationOption.targetFactionId,
|
||||
gameState
|
||||
nativeGameState.factions.values.toVector,
|
||||
nativeGameState.provinces.values.toVector
|
||||
)
|
||||
)
|
||||
}.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, ProvinceDistances}
|
||||
import net.eagle0.eagle.library.util.{CommandSelection, LegacyBattalionUtils, LegacyProvinceDistances}
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.ProvinceGoldSurplusCalculator.provinceGoldSurplus
|
||||
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
|
||||
|
||||
@@ -23,7 +23,7 @@ object MarchTowardProvinceCommandChooser {
|
||||
.map(dest => (opmc, gs.provinces(dest.provinceId)))
|
||||
.flatMap {
|
||||
case (opmc, p) =>
|
||||
ProvinceDistances
|
||||
LegacyProvinceDistances
|
||||
.distanceThroughFriendliesOption(
|
||||
destinationProvinceId,
|
||||
p.id,
|
||||
|
||||
@@ -19,7 +19,6 @@ import net.eagle0.eagle.library.util.command_choice_helpers.{
|
||||
CommandChoiceHelpers,
|
||||
CommandChooser,
|
||||
ExpandCommandSelector,
|
||||
FoodConsumptionUtils,
|
||||
FulfillQuestsCommandSelector,
|
||||
ImproveCommandSelector,
|
||||
OrganizeCommandSelector,
|
||||
@@ -29,9 +28,12 @@ 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
|
||||
|
||||
@@ -137,7 +139,7 @@ object MidGameAIClient {
|
||||
) =>
|
||||
val actingProvince = gameState.provinces(actingProvinceId)
|
||||
val foodMonthsToHoldBack =
|
||||
FoodConsumptionUtils.foodConsumptionMonthsToHold(
|
||||
LegacyFoodConsumptionUtils.foodConsumptionMonthsToHold(
|
||||
actingProvinceId,
|
||||
gameState
|
||||
)
|
||||
@@ -269,7 +271,7 @@ object MidGameAIClient {
|
||||
RandomState(
|
||||
ImproveCommandSelector.chosenImproveCommand(
|
||||
actingFactionId,
|
||||
gameState,
|
||||
GameStateConverter.fromProto(gameState),
|
||||
availableCommands
|
||||
),
|
||||
functionalRandom
|
||||
@@ -298,7 +300,7 @@ object MidGameAIClient {
|
||||
RandomState(
|
||||
ImproveCommandSelector.chosenImproveCommand(
|
||||
actingFactionId,
|
||||
gameState,
|
||||
GameStateConverter.fromProto(gameState),
|
||||
availableCommands
|
||||
),
|
||||
functionalRandom
|
||||
@@ -345,7 +347,18 @@ object MidGameAIClient {
|
||||
acs = availableCommands,
|
||||
choosersAndReasons = Vector(
|
||||
(
|
||||
ExpandCommandSelector.maybeChosenExpandCommand,
|
||||
(
|
||||
fid: FactionId,
|
||||
gs: GameState,
|
||||
acs: Vector[AvailableCommand],
|
||||
fr: FunctionalRandom
|
||||
) =>
|
||||
ExpandCommandSelector.maybeChosenExpandCommand(
|
||||
fid,
|
||||
GameStateConverter.fromProto(gs),
|
||||
acs,
|
||||
fr
|
||||
),
|
||||
"no hostile neighbors, expand"
|
||||
),
|
||||
(
|
||||
@@ -362,7 +375,7 @@ object MidGameAIClient {
|
||||
RandomState(
|
||||
ImproveCommandSelector.chosenImproveCommand(
|
||||
actingFactionId,
|
||||
gameState,
|
||||
GameStateConverter.fromProto(gameState),
|
||||
availableCommands
|
||||
),
|
||||
functionalRandom
|
||||
@@ -450,7 +463,7 @@ object MidGameAIClient {
|
||||
RandomState(
|
||||
ImproveCommandSelector.chosenImproveCommand(
|
||||
actingFactionId,
|
||||
gameState,
|
||||
GameStateConverter.fromProto(gameState),
|
||||
availableCommands
|
||||
),
|
||||
functionalRandom
|
||||
@@ -505,7 +518,7 @@ object MidGameAIClient {
|
||||
|
||||
AttackCommandChooser.chosenAttackCommand(
|
||||
actingFactionId,
|
||||
gs,
|
||||
GameStateConverter.fromProto(gs),
|
||||
acs,
|
||||
reconnedHeroCountFor,
|
||||
reconnedBattalionsFor
|
||||
@@ -525,13 +538,13 @@ object MidGameAIClient {
|
||||
AlmsCommandSelector
|
||||
.chosenAlmsForSupportCommand(
|
||||
actingFactionId = actingFactionId,
|
||||
gameState = gameState,
|
||||
gameState = GameStateConverter.fromProto(gameState),
|
||||
availableCommands = availableCommands
|
||||
)
|
||||
.orElse {
|
||||
ImproveCommandSelector.chosenImproveCommand(
|
||||
actingFactionId = actingFactionId,
|
||||
gameState = gameState,
|
||||
gameState = GameStateConverter.fromProto(gameState),
|
||||
availableCommands = availableCommands
|
||||
)
|
||||
}
|
||||
@@ -564,7 +577,7 @@ object MidGameAIClient {
|
||||
RandomState(
|
||||
AttackCommandChooser.chosenSupportAttackCommand(
|
||||
actingFactionId,
|
||||
gameState,
|
||||
GameStateConverter.fromProto(gameState),
|
||||
availableCommands
|
||||
),
|
||||
functionalRandom
|
||||
@@ -614,7 +627,7 @@ object MidGameAIClient {
|
||||
RandomState(
|
||||
SeekMoreLeadersCommandChooser.maybeSeekMoreLeadersCommand(
|
||||
actingFactionId,
|
||||
gameState,
|
||||
GameStateConverter.fromProto(gameState),
|
||||
availableCommands
|
||||
),
|
||||
functionalRandom
|
||||
@@ -790,7 +803,7 @@ object MidGameAIClient {
|
||||
OrganizeCommandSelector
|
||||
.chosenOrganizeCommandWithFoodSurplus(
|
||||
actingFactionId = fid,
|
||||
gameState = gs,
|
||||
gameState = GameStateConverter.fromProto(gs),
|
||||
availableCommands = acs,
|
||||
extraFoodSurplus = foodSurplus(
|
||||
gs,
|
||||
@@ -892,7 +905,7 @@ object MidGameAIClient {
|
||||
val furthestOrigin = candidateCommands.flatMap { opmc =>
|
||||
// distance to the faction leader province we're closest to
|
||||
factionLeaderProvincesNeedingHeroes.flatMap { flp =>
|
||||
ProvinceDistances
|
||||
LegacyProvinceDistances
|
||||
.distanceThroughFriendliesOption(
|
||||
p1 = flp.id,
|
||||
p2 = opmc.originProvinceId,
|
||||
|
||||
@@ -4,8 +4,8 @@ import net.eagle0.eagle.{FactionId, ProvinceId}
|
||||
import net.eagle0.eagle.api.available_command.{AvailableCommand, MarchAvailableCommand, MarchCommandFromOneProvince}
|
||||
import net.eagle0.eagle.internal.faction.Faction
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.library.util.{LegacyProvinceDistances, ProvinceDistances}
|
||||
import net.eagle0.eagle.library.util.CommandSelection
|
||||
import net.eagle0.eagle.library.util.ProvinceDistances
|
||||
import net.eagle0.eagle.library.util.ProvinceDistances.ProvinceAndDistance
|
||||
|
||||
object MoveLeaderToBetterProvinceCommandChooser {
|
||||
@@ -53,7 +53,7 @@ object MoveLeaderToBetterProvinceCommandChooser {
|
||||
mcfop.originProvinceId,
|
||||
gs
|
||||
).flatMap { desiredPid =>
|
||||
ProvinceDistances
|
||||
LegacyProvinceDistances
|
||||
.closestProvinceThroughFriendliesOption(
|
||||
desiredPid,
|
||||
mcfop.availableDestinationProvinces.map(_.provinceId).toVector,
|
||||
|
||||
@@ -39,10 +39,12 @@ import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSele
|
||||
randomSelectionForType,
|
||||
selectionForType
|
||||
}
|
||||
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
|
||||
import net.eagle0.eagle.library.util.faction_utils.{FactionUtils, 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 {
|
||||
@@ -140,6 +142,20 @@ 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,9 +4,6 @@ import net.eagle0.common.MoreOption
|
||||
import net.eagle0.eagle.api.available_command.{AvailableCommand, MarchAvailableCommand}
|
||||
import net.eagle0.eagle.api.selected_command.MarchSelectedCommand
|
||||
import net.eagle0.eagle.common.combat_unit.CombatUnit
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.internal.hero.Hero
|
||||
import net.eagle0.eagle.internal.province.Province
|
||||
import net.eagle0.eagle.library.settings.{LoyaltyGainFromFeast, MinimumLoyaltyForSwearBrotherhood}
|
||||
import net.eagle0.eagle.library.util.{CommandSelection, ProvinceDistances}
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.{
|
||||
@@ -15,26 +12,30 @@ import net.eagle0.eagle.library.util.command_choice_helpers.{
|
||||
SwearBrotherhoodCommandSelector,
|
||||
SwornBrotherChooser
|
||||
}
|
||||
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
|
||||
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
|
||||
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
|
||||
import net.eagle0.eagle.library.util.hero.HeroUtils
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.model.state.hero.HeroT
|
||||
import net.eagle0.eagle.model.state.province.ProvinceT
|
||||
import net.eagle0.eagle.FactionId
|
||||
|
||||
/** Protoless version of SeekMoreLeadersCommandChooser. */
|
||||
object SeekMoreLeadersCommandChooser {
|
||||
private def needsMoreLeaders(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState
|
||||
): Boolean =
|
||||
gameState.factions(actingFactionId).leaders.size < SwornBrotherChooser
|
||||
gameState.factions(actingFactionId).leaderIds.size < SwornBrotherChooser
|
||||
.desiredNumberOfLeaders(
|
||||
LegacyFactionUtils.provinceCount(actingFactionId, gameState)
|
||||
FactionUtils.provinces(actingFactionId, gameState.provinces.values.toVector).size
|
||||
)
|
||||
|
||||
case class Candidate(hero: Hero, province: Province)
|
||||
case class Candidate(hero: HeroT, province: ProvinceT)
|
||||
private def maybeMoveTowardDestination(
|
||||
command: MarchAvailableCommand,
|
||||
hero: Hero,
|
||||
startingProvince: Province,
|
||||
factionHeadProvince: Province,
|
||||
hero: HeroT,
|
||||
startingProvince: ProvinceT,
|
||||
factionHeadProvince: ProvinceT,
|
||||
gameState: GameState
|
||||
): Option[CommandSelection] = {
|
||||
val opmc = command.oneProvinceCommands
|
||||
@@ -50,7 +51,7 @@ object SeekMoreLeadersCommandChooser {
|
||||
factionHeadProvince.id,
|
||||
p.id,
|
||||
factionHeadProvince.rulingFactionId.get,
|
||||
gameState
|
||||
gameState.provinces
|
||||
)
|
||||
.map(distance => (opmc, p, distance))
|
||||
}
|
||||
@@ -83,23 +84,24 @@ object SeekMoreLeadersCommandChooser {
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
acs: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
LegacyFactionUtils
|
||||
.provinces(actingFactionId, gameState)
|
||||
): Option[CommandSelection] = {
|
||||
val provincesVector = gameState.provinces.values.toVector
|
||||
FactionUtils
|
||||
.provinces(actingFactionId, provincesVector)
|
||||
.find(
|
||||
_.rulingFactionHeroIds
|
||||
.contains(gameState.factions(actingFactionId).factionHeadId)
|
||||
)
|
||||
.flatMap { provinceWithFactionHead =>
|
||||
val candidates =
|
||||
LegacyFactionUtils
|
||||
.provinces(actingFactionId, gameState)
|
||||
FactionUtils
|
||||
.provinces(actingFactionId, provincesVector)
|
||||
.filter(_.rulingFactionHeroIds.length > 1)
|
||||
.flatMap { province =>
|
||||
SwornBrotherChooser
|
||||
.bestChoice(
|
||||
province.rulingFactionHeroIds.map(gameState.heroes),
|
||||
gameState.factions(actingFactionId).leaders.toVector
|
||||
gameState.factions(actingFactionId).leaderIds
|
||||
)
|
||||
.map(hero => Candidate(hero, province))
|
||||
}
|
||||
@@ -117,7 +119,7 @@ object SeekMoreLeadersCommandChooser {
|
||||
}
|
||||
|
||||
marchableCandidates
|
||||
.maxByOption(x => LegacyHeroUtils.power(x._2.hero))
|
||||
.maxByOption(x => HeroUtils.power(x._2.hero))
|
||||
.flatMap {
|
||||
case (command, candidate) =>
|
||||
maybeMoveTowardDestination(
|
||||
@@ -129,6 +131,7 @@ object SeekMoreLeadersCommandChooser {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def maybeSeekMoreLeadersCommand(
|
||||
actingFactionId: FactionId,
|
||||
@@ -144,14 +147,13 @@ object SeekMoreLeadersCommandChooser {
|
||||
// First try a hero that's already here
|
||||
val desiredHero = SwornBrotherChooser.bestChoice(
|
||||
leaderProvince.rulingFactionHeroIds.map(gameState.heroes),
|
||||
faction.leaders.toVector
|
||||
faction.leaderIds
|
||||
)
|
||||
|
||||
desiredHero.flatMap { hero =>
|
||||
SwearBrotherhoodCommandSelector
|
||||
.chosenCommandForSpecificHero(
|
||||
actingFactionId = actingFactionId,
|
||||
gameState = gameState,
|
||||
availableCommands = acs,
|
||||
desiredHeroId = hero.id
|
||||
)
|
||||
@@ -161,7 +163,6 @@ object SeekMoreLeadersCommandChooser {
|
||||
) {
|
||||
CommandChoiceHelpers.chosenFeastCommandIfAvailable(
|
||||
actingFactionId = actingFactionId,
|
||||
gameState = gameState,
|
||||
availableCommands = acs,
|
||||
reason = s"want to make hero ${hero.id} a leader"
|
||||
)
|
||||
@@ -170,7 +171,6 @@ object SeekMoreLeadersCommandChooser {
|
||||
.orElse {
|
||||
HeroGiftCommandSelector.chosenCommandForSpecificHero(
|
||||
actingFactionId = actingFactionId,
|
||||
gameState = gameState,
|
||||
availableCommands = acs,
|
||||
heroId = hero.id,
|
||||
reason = "want to make this hero a leader"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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",
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,182 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// 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}")
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// 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,14 +309,16 @@ case class EngineImpl(
|
||||
initialState = this.currentState,
|
||||
actionResultApplier = ActionResultApplierImpl(Some(ScalaRuntimeValidator)),
|
||||
functionalRandom = SeededRandom(this.currentState.randomSeed)
|
||||
).withTCommand { gs =>
|
||||
commandFactory.makeTCommand(
|
||||
actingFactionId = factionId,
|
||||
gameState = gs,
|
||||
availableCommand = availableCommand,
|
||||
selectedCommand = selectedCommand
|
||||
)
|
||||
}.withProtolessSequentialResultsAction(gs => HeroBackstoryUpdateActionGenerator(gs))
|
||||
).withTCommandAndLastCommand(
|
||||
commandGen = gs =>
|
||||
commandFactory.makeTCommand(
|
||||
actingFactionId = factionId,
|
||||
gameState = gs,
|
||||
availableCommand = availableCommand,
|
||||
selectedCommand = selectedCommand
|
||||
),
|
||||
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,8 +9,6 @@ case class ActionResultWithResultingState(
|
||||
)
|
||||
|
||||
trait ActionResultApplier {
|
||||
def xpForStatBump(stat: Int): Int
|
||||
|
||||
def applyActionResults(
|
||||
startingState: GameState,
|
||||
results: Iterable[ActionResultT]
|
||||
|
||||
@@ -56,8 +56,6 @@ 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,8 +4,6 @@ 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]
|
||||
|
||||
-5
@@ -47,7 +47,6 @@ 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
|
||||
@@ -57,10 +56,6 @@ 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,8 +9,6 @@ case class ActionResultTWithResultingState(
|
||||
)
|
||||
|
||||
trait ActionResultTApplier {
|
||||
def xpForStatBump(stat: Int): Int
|
||||
|
||||
def applyActionResults(
|
||||
startingState: GameState,
|
||||
results: Iterable[ActionResultT]
|
||||
|
||||
+1
-2
@@ -6,6 +6,7 @@ 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))
|
||||
@@ -18,8 +19,6 @@ 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,8 +242,6 @@ 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",
|
||||
|
||||
+1
@@ -108,6 +108,7 @@ 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 =
|
||||
|
||||
+15
-8
@@ -7,9 +7,14 @@ 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.{BattalionSuitability, LegacyBattalionUtils}
|
||||
import net.eagle0.eagle.library.util.battalion_suitability.BattalionSuitability
|
||||
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(
|
||||
@@ -71,9 +76,9 @@ object AvailableDefendCommandsFactory extends AvailableCommandsFactoryForType {
|
||||
|
||||
val recommendedUnits = CombatUnitSelector.selectedCombatBattalions(
|
||||
desiredCount = availableHeroes.size.min(MaxCombatUnitCountPerSide.intValue),
|
||||
heroes = availableHeroes.toVector,
|
||||
battalions = availableBattalions.toVector,
|
||||
battalionTypes = gameState.battalionTypes.toVector
|
||||
heroes = availableHeroes.map(HeroConverter.fromProto).toVector,
|
||||
battalions = availableBattalions.map(BattalionConverter.fromProto).toVector,
|
||||
battalionTypes = gameState.battalionTypes.map(BattalionTypeConverter.fromProto).toVector
|
||||
)
|
||||
val hostileUnits = hostileArmies
|
||||
.flatMap(_.armies)
|
||||
@@ -86,10 +91,12 @@ object AvailableDefendCommandsFactory extends AvailableCommandsFactoryForType {
|
||||
.toVector
|
||||
.map(_.id),
|
||||
actingProvinceId = province.id,
|
||||
suitableBattalionsForHeroes = BattalionSuitability.suitableBattalionsForHeroes(
|
||||
hs = availableHeroes.toVector,
|
||||
bs = availableBattalions.toVector,
|
||||
bts = gameState.battalionTypes.toVector
|
||||
suitableBattalionsForHeroes = SuitableBattalionsConverter.toProtoMap(
|
||||
BattalionSuitability.suitableBattalionsForHeroes(
|
||||
hs = availableHeroes.map(HeroConverter.fromProto).toVector,
|
||||
bs = availableBattalions.map(BattalionConverter.fromProto).toVector,
|
||||
bts = gameState.battalionTypes.map(BattalionTypeConverter.fromProto).toVector
|
||||
)
|
||||
),
|
||||
availableBattalions = availableBattalionsWithCosts,
|
||||
availableFleeProvinceIds = sortedFleeProvinces(from = province, gs = gameState),
|
||||
|
||||
+17
-11
@@ -11,10 +11,15 @@ 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.{BattalionSuitability, LegacyBattalionUtils, ShardokMapInfo}
|
||||
import net.eagle0.eagle.library.util.{LegacyBattalionUtils, ShardokMapInfo}
|
||||
import net.eagle0.eagle.library.util.battalion_suitability.BattalionSuitability
|
||||
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 {
|
||||
|
||||
@@ -67,14 +72,14 @@ object AvailableMarchCommandFactory extends AvailableCommandsFactoryForType {
|
||||
Option.when(recommendedSendCount > 0) {
|
||||
val units = CombatUnitSelector.selectedCombatBattalions(
|
||||
desiredCount = recommendedSendCount,
|
||||
heroes = availableHeroes.toVector,
|
||||
battalions = availableBattalions.toVector,
|
||||
battalionTypes = gameState.battalionTypes.toVector
|
||||
heroes = availableHeroes.map(HeroConverter.fromProto).toVector,
|
||||
battalions = availableBattalions.map(BattalionConverter.fromProto).toVector,
|
||||
battalionTypes = gameState.battalionTypes.map(BattalionTypeConverter.fromProto).toVector
|
||||
)
|
||||
RecommendedBattleConfig(
|
||||
units = units,
|
||||
food = MarchSuppliesHelpers.foodToTakeOnAttack(
|
||||
units.flatMap(_.battalionId).map(gameState.battalions),
|
||||
units.flatMap(_.battalionId).map(gameState.battalions).map(BattalionConverter.fromProto),
|
||||
originProvince.food
|
||||
),
|
||||
gold = MarchSuppliesHelpers.goldToTakeOnAttack(
|
||||
@@ -97,12 +102,13 @@ object AvailableMarchCommandFactory extends AvailableCommandsFactoryForType {
|
||||
)
|
||||
}.toVector,
|
||||
availableDestinationProvinces = availableDestinations,
|
||||
suitableBattalionsForHeroes = BattalionSuitability
|
||||
.suitableBattalionsForHeroes(
|
||||
hs = availableHeroes.toVector,
|
||||
bs = availableBattalions.toVector,
|
||||
bts = gameState.battalionTypes.toVector
|
||||
),
|
||||
suitableBattalionsForHeroes = SuitableBattalionsConverter.toProtoMap(
|
||||
BattalionSuitability.suitableBattalionsForHeroes(
|
||||
hs = availableHeroes.map(HeroConverter.fromProto).toVector,
|
||||
bs = availableBattalions.map(BattalionConverter.fromProto).toVector,
|
||||
bts = gameState.battalionTypes.map(BattalionTypeConverter.fromProto).toVector
|
||||
)
|
||||
),
|
||||
originProvinceId = originProvince.id,
|
||||
goldAvailable = originProvince.gold,
|
||||
foodAvailable = originProvince.food,
|
||||
|
||||
@@ -187,10 +187,16 @@ 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",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -446,13 +452,19 @@ 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,18 +1,5 @@
|
||||
load("@rules_scala//scala:scala.bzl", "scala_library")
|
||||
|
||||
scala_library(
|
||||
name = "action_pkg",
|
||||
srcs = ["package.scala"],
|
||||
visibility = [
|
||||
"//src/main/scala/net/eagle0/eagle/library:__subpackages__",
|
||||
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
|
||||
],
|
||||
runtime_deps = [
|
||||
"@maven//:com_thesamet_scalapb_lenses_3",
|
||||
],
|
||||
deps = ["//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto"],
|
||||
)
|
||||
|
||||
scala_library(
|
||||
name = "check_for_faction_changes_action",
|
||||
srcs = ["CheckForFactionChangesAction.scala"],
|
||||
@@ -42,6 +29,7 @@ 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",
|
||||
@@ -116,7 +104,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",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/battalion_type_finder:legacy_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",
|
||||
@@ -149,14 +137,16 @@ 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",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -789,7 +779,6 @@ 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",
|
||||
@@ -886,7 +875,6 @@ 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",
|
||||
|
||||
+41
-16
@@ -6,8 +6,10 @@ 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}
|
||||
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.types.{
|
||||
FactionDestroyedResultType,
|
||||
FactionLeaderRemovedResultType,
|
||||
@@ -208,24 +210,47 @@ 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 = fs
|
||||
.map(revisedFaction =>
|
||||
ChangedFactionC(
|
||||
factionId = revisedFaction.id,
|
||||
removedLeaderHeroIds = factions
|
||||
.find(
|
||||
_.id == revisedFaction.id
|
||||
)
|
||||
.get
|
||||
.leaderIds
|
||||
.filter(killedHeroIds.contains),
|
||||
newFactionHeadHeroId = Some(revisedFaction.factionHeadId)
|
||||
)
|
||||
)
|
||||
.toVector
|
||||
changedFactions = changedFactionData.map(_._1),
|
||||
newGeneratedTextRequests = changedFactionData.map(_._2),
|
||||
newNotifications = changedFactionData.map(_._3)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
+2
-2
@@ -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 =
|
||||
BattalionTypeFinder.battalionType(id, battalionTypes)
|
||||
LegacyBattalionTypeFinder.battalionType(id, battalionTypes)
|
||||
|
||||
private def battalionWithId(id: BattalionId): Option[BattalionT] =
|
||||
battalions.find(_.id == id)
|
||||
|
||||
+310
-327
@@ -28,47 +28,14 @@ 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_UNKNOWN_UNSPECIFIED,
|
||||
SHATTERED_ARMY_REASON_WITHDRAW,
|
||||
Unrecognized
|
||||
SHATTERED_ARMY_REASON_WITHDRAW
|
||||
}
|
||||
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 {
|
||||
@@ -79,325 +46,341 @@ object ChronicleEventGenerator {
|
||||
|
||||
private def notificationEntries(
|
||||
awrs: ActionWithResultingState
|
||||
): 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
|
||||
)
|
||||
): 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
|
||||
)
|
||||
|
||||
case CapturedHeroExiledDetails(
|
||||
capturedFromFactionId,
|
||||
provinceId,
|
||||
exiledHeroId,
|
||||
exilingFactionId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
CapturedHeroExiledEvent(
|
||||
capturedFromFactionId = capturedFromFactionId,
|
||||
provinceId = provinceId,
|
||||
exiledHeroId = exiledHeroId,
|
||||
exilingFactionId = exilingFactionId
|
||||
)
|
||||
case CapturedHeroExiledDetails(
|
||||
capturedFromFactionId,
|
||||
provinceId,
|
||||
exiledHeroId,
|
||||
exilingFactionId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
CapturedHeroExiledChronicleEvent(
|
||||
date = date,
|
||||
capturedFromFactionId = capturedFromFactionId,
|
||||
provinceId = provinceId,
|
||||
exiledHeroId = exiledHeroId,
|
||||
exilingFactionId = exilingFactionId
|
||||
)
|
||||
|
||||
case CapturedHeroExecutedDetails(
|
||||
capturedFromFactionId,
|
||||
provinceId,
|
||||
executedHeroId,
|
||||
executingFactionId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
CapturedHeroExecutedEvent(
|
||||
capturedFromFactionId = capturedFromFactionId,
|
||||
provinceId = provinceId,
|
||||
executedHeroId = executedHeroId,
|
||||
executingFactionId = executingFactionId
|
||||
)
|
||||
case CapturedHeroExecutedDetails(
|
||||
capturedFromFactionId,
|
||||
provinceId,
|
||||
executedHeroId,
|
||||
executingFactionId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
CapturedHeroExecutedChronicleEvent(
|
||||
date = date,
|
||||
capturedFromFactionId = capturedFromFactionId,
|
||||
provinceId = provinceId,
|
||||
executedHeroId = executedHeroId,
|
||||
executingFactionId = executingFactionId
|
||||
)
|
||||
|
||||
case CapturedHeroImprisonedDetails(
|
||||
capturedFromFactionId,
|
||||
provinceId,
|
||||
imprisonedHeroId,
|
||||
imprisoningFactionId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
CapturedHeroImprisonedEvent(
|
||||
capturedFromFactionId = capturedFromFactionId,
|
||||
provinceId = provinceId,
|
||||
imprisonedHeroId = imprisonedHeroId,
|
||||
imprisoningFactionId = imprisoningFactionId
|
||||
)
|
||||
case CapturedHeroImprisonedDetails(
|
||||
capturedFromFactionId,
|
||||
provinceId,
|
||||
imprisonedHeroId,
|
||||
imprisoningFactionId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
CapturedHeroImprisonedChronicleEvent(
|
||||
date = date,
|
||||
capturedFromFactionId = capturedFromFactionId,
|
||||
provinceId = provinceId,
|
||||
imprisonedHeroId = imprisonedHeroId,
|
||||
imprisoningFactionId = imprisoningFactionId
|
||||
)
|
||||
|
||||
case HeroDepartureDetails(
|
||||
departingHeroId,
|
||||
fromFactionId,
|
||||
provinceId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
HeroDepartedEvent(
|
||||
departingHeroId = departingHeroId,
|
||||
fromFactionId = fromFactionId,
|
||||
provinceId = provinceId
|
||||
)
|
||||
case HeroDepartureDetails(
|
||||
departingHeroId,
|
||||
fromFactionId,
|
||||
provinceId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
HeroDepartedChronicleEvent(
|
||||
date = date,
|
||||
departingHeroId = departingHeroId,
|
||||
fromFactionId = fromFactionId,
|
||||
provinceId = provinceId
|
||||
)
|
||||
|
||||
case SuppressBeastsFailedDetails(
|
||||
heroId,
|
||||
provinceId,
|
||||
_ /* singularName */,
|
||||
pluralName,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
SuppressBeastsFailedEvent(
|
||||
heroId = heroId,
|
||||
provinceId = provinceId,
|
||||
pluralName = pluralName
|
||||
)
|
||||
case SuppressBeastsFailedDetails(
|
||||
heroId,
|
||||
provinceId,
|
||||
singularName,
|
||||
pluralName,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
SuppressBeastsFailedChronicleEvent(
|
||||
date = date,
|
||||
heroId = heroId,
|
||||
provinceId = provinceId,
|
||||
singularName = singularName,
|
||||
pluralName = pluralName
|
||||
)
|
||||
|
||||
case PrisonerExecutedDetails(
|
||||
lastFactionId,
|
||||
provinceId,
|
||||
executedHeroId,
|
||||
executingFactionId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
PrisonerExecutedEvent(
|
||||
lastFactionId = lastFactionId,
|
||||
provinceId = provinceId,
|
||||
executedHeroId = executedHeroId,
|
||||
executingFactionId = executingFactionId
|
||||
)
|
||||
case PrisonerExecutedDetails(
|
||||
lastFactionId,
|
||||
provinceId,
|
||||
executedHeroId,
|
||||
executingFactionId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
PrisonerExecutedChronicleEvent(
|
||||
date = date,
|
||||
lastFactionId = lastFactionId,
|
||||
provinceId = provinceId,
|
||||
executedHeroId = executedHeroId,
|
||||
executingFactionId = executingFactionId
|
||||
)
|
||||
|
||||
case PrisonerExchangeDetails(
|
||||
hero1Id,
|
||||
hero1FactionId,
|
||||
hero1ProvinceId,
|
||||
hero2Id,
|
||||
hero2FactionId,
|
||||
hero2ProvinceId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
PrisonersExchangedEvent(
|
||||
hero1Id = hero1Id,
|
||||
hero1FactionId = hero1FactionId,
|
||||
hero1ProvinceId = hero1ProvinceId,
|
||||
hero2Id = hero2Id,
|
||||
hero2FactionId = hero2FactionId,
|
||||
hero2ProvinceId = hero2ProvinceId
|
||||
)
|
||||
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 ProvinceConqueredDetails(
|
||||
provinceId,
|
||||
conqueringFactionId,
|
||||
otherAttackingFactionIds,
|
||||
defendingFactionId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
ProvinceConqueredEvent(
|
||||
provinceId = provinceId,
|
||||
conqueringFactionId = conqueringFactionId,
|
||||
otherAttackingFactionIds = otherAttackingFactionIds,
|
||||
defendingFactionId = defendingFactionId
|
||||
)
|
||||
case ProvinceConqueredDetails(
|
||||
provinceId,
|
||||
conqueringFactionId,
|
||||
otherAttackingFactionIds,
|
||||
defendingFactionId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
ProvinceConqueredChronicleEvent(
|
||||
date = date,
|
||||
provinceId = provinceId,
|
||||
conqueringFactionId = conqueringFactionId,
|
||||
otherAttackingFactionIds = otherAttackingFactionIds.toVector,
|
||||
defendingFactionId = defendingFactionId
|
||||
)
|
||||
|
||||
case ProvinceExpansionDetails(
|
||||
provinceId,
|
||||
expandingFactionId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
ProvinceExpansionEvent(
|
||||
provinceId = provinceId,
|
||||
expandingFactionId = expandingFactionId
|
||||
)
|
||||
case ProvinceExpansionDetails(
|
||||
provinceId,
|
||||
expandingFactionId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
ProvinceExpansionChronicleEvent(
|
||||
date = date,
|
||||
provinceId = provinceId,
|
||||
expandingFactionId = expandingFactionId
|
||||
)
|
||||
|
||||
case ProvinceHeldDetails(
|
||||
provinceId,
|
||||
attackingFids,
|
||||
defendingFid,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
ProvinceHeldEvent(
|
||||
provinceId = provinceId,
|
||||
attackingFactionIds = attackingFids,
|
||||
defendingFactionId = defendingFid
|
||||
)
|
||||
case ProvinceHeldDetails(
|
||||
provinceId,
|
||||
attackingFids,
|
||||
defendingFid,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
ProvinceHeldChronicleEvent(
|
||||
date = date,
|
||||
provinceId = provinceId,
|
||||
attackingFactionIds = attackingFids.toVector,
|
||||
defendingFactionId = defendingFid
|
||||
)
|
||||
|
||||
case SwearBrotherhoodDetails(
|
||||
factionId,
|
||||
newBrotherHeroId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
SwearBrotherhoodEvent(
|
||||
factionId = factionId,
|
||||
newBrotherHeroId = newBrotherHeroId
|
||||
)
|
||||
case SwearBrotherhoodDetails(
|
||||
factionId,
|
||||
newBrotherHeroId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
SwearBrotherhoodChronicleEvent(
|
||||
date = date,
|
||||
factionId = factionId,
|
||||
newBrotherHeroId = newBrotherHeroId
|
||||
)
|
||||
|
||||
case InvitationAcceptedDetails(
|
||||
offeringFactionId,
|
||||
invitedFactionId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
InvitationAcceptedEvent(
|
||||
offeringFactionId = offeringFactionId,
|
||||
invitedFactionId = invitedFactionId
|
||||
)
|
||||
case InvitationAcceptedDetails(
|
||||
offeringFactionId,
|
||||
invitedFactionId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
InvitationAcceptedChronicleEvent(
|
||||
date = date,
|
||||
offeringFactionId = offeringFactionId,
|
||||
invitedFactionId = invitedFactionId
|
||||
)
|
||||
|
||||
case InvitationAmbassadorImprisonedDetails(
|
||||
offeringFactionId,
|
||||
invitedFactionId,
|
||||
ambassadorHeroId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
InvitationAmbassadorImprisonedEvent(
|
||||
offeringFactionId = offeringFactionId,
|
||||
invitedFactionId = invitedFactionId,
|
||||
ambassadorHeroId = ambassadorHeroId
|
||||
)
|
||||
case InvitationAmbassadorImprisonedDetails(
|
||||
offeringFactionId,
|
||||
invitedFactionId,
|
||||
ambassadorHeroId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
InvitationAmbassadorImprisonedChronicleEvent(
|
||||
date = date,
|
||||
offeringFactionId = offeringFactionId,
|
||||
invitedFactionId = invitedFactionId,
|
||||
ambassadorHeroId = ambassadorHeroId
|
||||
)
|
||||
|
||||
case TruceAcceptedDetails(
|
||||
offeringFactionId,
|
||||
targetFactionId,
|
||||
ambassadorHeroId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
TruceAcceptedEvent(
|
||||
offeringFactionId = offeringFactionId,
|
||||
targetFactionId = targetFactionId,
|
||||
ambassadorHeroId = ambassadorHeroId
|
||||
)
|
||||
case TruceAcceptedDetails(
|
||||
offeringFactionId,
|
||||
targetFactionId,
|
||||
ambassadorHeroId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
TruceAcceptedChronicleEvent(
|
||||
date = date,
|
||||
offeringFactionId = offeringFactionId,
|
||||
targetFactionId = targetFactionId,
|
||||
ambassadorHeroId = ambassadorHeroId
|
||||
)
|
||||
|
||||
case TruceAmbassadorImprisonedDetails(
|
||||
offeringFactionId,
|
||||
targetFactionId,
|
||||
ambassadorHeroId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
TruceAmbassadorImprisonedEvent(
|
||||
offeringFactionId = offeringFactionId,
|
||||
targetFactionId = targetFactionId,
|
||||
ambassadorHeroId = ambassadorHeroId
|
||||
)
|
||||
case TruceAmbassadorImprisonedDetails(
|
||||
offeringFactionId,
|
||||
targetFactionId,
|
||||
ambassadorHeroId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
TruceAmbassadorImprisonedChronicleEvent(
|
||||
date = date,
|
||||
offeringFactionId = offeringFactionId,
|
||||
targetFactionId = targetFactionId,
|
||||
ambassadorHeroId = ambassadorHeroId
|
||||
)
|
||||
|
||||
case BreakAllianceAcceptedDetails(
|
||||
offeringFactionId,
|
||||
targetFactionId,
|
||||
ambassadorHeroId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
BreakAllianceEvent(
|
||||
offeringFactionId = offeringFactionId,
|
||||
targetFactionId = targetFactionId,
|
||||
ambassadorHeroId = ambassadorHeroId
|
||||
)
|
||||
case BreakAllianceAcceptedDetails(
|
||||
offeringFactionId,
|
||||
targetFactionId,
|
||||
ambassadorHeroId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
BreakAllianceChronicleEvent(
|
||||
date = date,
|
||||
offeringFactionId = offeringFactionId,
|
||||
targetFactionId = targetFactionId,
|
||||
ambassadorHeroId = ambassadorHeroId
|
||||
)
|
||||
|
||||
case BreakAllianceAmbassadorImprisonedDetails(
|
||||
offeringFactionId,
|
||||
targetFactionId,
|
||||
ambassadorHeroId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
BreakAllianceAmbassadorImprisonedEvent(
|
||||
offeringFactionId = offeringFactionId,
|
||||
targetFactionId = targetFactionId,
|
||||
ambassadorHeroId = ambassadorHeroId
|
||||
)
|
||||
case BreakAllianceAmbassadorImprisonedDetails(
|
||||
offeringFactionId,
|
||||
targetFactionId,
|
||||
ambassadorHeroId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
BreakAllianceAmbassadorImprisonedChronicleEvent(
|
||||
date = date,
|
||||
offeringFactionId = offeringFactionId,
|
||||
targetFactionId = targetFactionId,
|
||||
ambassadorHeroId = ambassadorHeroId
|
||||
)
|
||||
|
||||
case OutlawApprehendedDetails(
|
||||
factionId,
|
||||
provinceId,
|
||||
apprehendedHeroId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
OutlawApprehendedEvent(
|
||||
factionId = factionId,
|
||||
provinceId = provinceId,
|
||||
apprehendedHeroId = apprehendedHeroId
|
||||
)
|
||||
case OutlawApprehendedDetails(
|
||||
factionId,
|
||||
provinceId,
|
||||
apprehendedHeroId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
OutlawApprehendedChronicleEvent(
|
||||
date = date,
|
||||
factionId = factionId,
|
||||
provinceId = provinceId,
|
||||
apprehendedHeroId = apprehendedHeroId
|
||||
)
|
||||
|
||||
case RansomPaidDetails(
|
||||
ransomedHeroId,
|
||||
ransomPaidByFactionId,
|
||||
ransomPaidToFactionId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
RansomPaidEvent(
|
||||
ransomedHeroId = ransomedHeroId,
|
||||
ransomPaidByFactionId = ransomPaidByFactionId,
|
||||
ransomPaidToFactionId = ransomPaidToFactionId
|
||||
)
|
||||
case RansomPaidDetails(
|
||||
ransomedHeroId,
|
||||
ransomPaidByFactionId,
|
||||
ransomPaidToFactionId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
RansomPaidChronicleEvent(
|
||||
date = date,
|
||||
ransomedHeroId = ransomedHeroId,
|
||||
ransomPaidByFactionId = ransomPaidByFactionId,
|
||||
ransomPaidToFactionId = ransomPaidToFactionId
|
||||
)
|
||||
|
||||
case RansomRejectedDetails(
|
||||
ransomedHeroId,
|
||||
ransomRefusedByFactionId,
|
||||
ransomOfferingFactionId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
RansomRejectedEvent(
|
||||
ransomedHeroId = ransomedHeroId,
|
||||
ransomRejectedByFactionId = ransomRefusedByFactionId,
|
||||
ransomOfferingFactionId = ransomOfferingFactionId
|
||||
)
|
||||
case RansomRejectedDetails(
|
||||
ransomedHeroId,
|
||||
ransomRefusedByFactionId,
|
||||
ransomOfferingFactionId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
RansomRejectedChronicleEvent(
|
||||
date = date,
|
||||
ransomedHeroId = ransomedHeroId,
|
||||
ransomRejectedByFactionId = ransomRefusedByFactionId,
|
||||
ransomOfferingFactionId = ransomOfferingFactionId
|
||||
)
|
||||
|
||||
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 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 VassalExiledDetails(
|
||||
factionId,
|
||||
provinceId,
|
||||
exiledHeroId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
VassalExiledEvent(
|
||||
factionId = factionId,
|
||||
provinceId = provinceId,
|
||||
exiledHeroId = exiledHeroId
|
||||
)
|
||||
}
|
||||
|
||||
detailList.map { details =>
|
||||
EventForChronicle(
|
||||
awrs.gameState.currentDate,
|
||||
details = details
|
||||
)
|
||||
}
|
||||
case VassalExiledDetails(
|
||||
factionId,
|
||||
provinceId,
|
||||
exiledHeroId,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
VassalExiledChronicleEvent(
|
||||
date = date,
|
||||
factionId = factionId,
|
||||
provinceId = provinceId,
|
||||
exiledHeroId = exiledHeroId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private def basicActionResultEntries(
|
||||
awrs: ActionWithResultingState
|
||||
): Vector[EventForChronicle] =
|
||||
): Vector[ChronicleEvent] =
|
||||
awrs.actionResult.`type` match {
|
||||
case FACTION_DESTROYED =>
|
||||
val date = DateConverter.fromProto(awrs.gameState.currentDate)
|
||||
awrs.actionResult.removedFactionIds.map { factionId =>
|
||||
EventForChronicle(
|
||||
date = awrs.gameState.currentDate,
|
||||
details = FactionDestroyedEvent(factionId = factionId)
|
||||
FactionDestroyedChronicleEvent(
|
||||
date = date,
|
||||
factionId = factionId
|
||||
)
|
||||
}.toVector
|
||||
|
||||
@@ -407,7 +390,7 @@ object ChronicleEventGenerator {
|
||||
def eventTextEntries(
|
||||
gameHistory: GameHistory,
|
||||
since: Date
|
||||
): Vector[EventForChronicle] =
|
||||
): Vector[ChronicleEvent] =
|
||||
gameHistory.sinceDate(since).flatMap { (awrs: ActionWithResultingState) =>
|
||||
notificationEntries(awrs) ++ basicActionResultEntries(awrs)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ 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
|
||||
@@ -117,14 +116,12 @@ 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))
|
||||
)
|
||||
.map(ChronicleEventConverter.fromProto)
|
||||
new_entries = ChronicleEventGenerator.eventTextEntries(
|
||||
gameHistory = gameHistory,
|
||||
since = gs.chronicleEntries.lastOption
|
||||
.map(_.date)
|
||||
.getOrElse(Date(year = 0, month = Date.Month.January))
|
||||
)
|
||||
)
|
||||
)
|
||||
else Vector()
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ case class PerformVassalCommandsPhaseAction(
|
||||
commandOptions.collectFirst {
|
||||
case ac: RestAvailableCommand =>
|
||||
ac
|
||||
}.isDefined && shouldRest(heroes)
|
||||
}.isDefined && shouldRestProto(heroes)
|
||||
) {
|
||||
chosenRestCommand(actingFactionId, gsProto, commandOptions, reason)
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
package net.eagle0.eagle.library.actions.impl
|
||||
|
||||
import net.eagle0.eagle.internal.action_result.ActionResult
|
||||
import scalapb.lenses
|
||||
import scalapb.lenses.Lens
|
||||
|
||||
package object action {
|
||||
type ActionResultProtoUpdater =
|
||||
Lens[ActionResult, ActionResult] => lenses.Mutation[ActionResult]
|
||||
}
|
||||
@@ -1410,7 +1410,6 @@ 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,7 +189,6 @@ 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,22 +1,10 @@
|
||||
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,6 +347,24 @@ 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"],
|
||||
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
+35
@@ -1,6 +1,7 @@
|
||||
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,
|
||||
@@ -79,6 +80,15 @@ 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 {
|
||||
@@ -176,4 +186,29 @@ 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,43 +35,6 @@ 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"],
|
||||
@@ -311,10 +274,24 @@ scala_library(
|
||||
"//src/test/scala/net/eagle0/eagle/library/util:__pkg__",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/province",
|
||||
],
|
||||
)
|
||||
|
||||
scala_library(
|
||||
name = "legacy_province_distances",
|
||||
srcs = ["LegacyProvinceDistances.scala"],
|
||||
visibility = [
|
||||
"//src/main/scala/net/eagle0/eagle:__subpackages__",
|
||||
"//src/test/scala/net/eagle0/eagle/library/util:__pkg__",
|
||||
],
|
||||
deps = [
|
||||
":province_distances",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/province",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -194,7 +194,8 @@ object GameStateViewDiffer {
|
||||
changedFactionRelationships = after.factionRelationships.diff(before.factionRelationships),
|
||||
addedIncomingDiplomacyOffers = after.incomingDiplomacyOffers.diff(before.incomingDiplomacyOffers),
|
||||
removedIncomingDiplomacyOffers = before.incomingDiplomacyOffers.diff(after.incomingDiplomacyOffers),
|
||||
newFocusProvinceId = after.focusProvinceId
|
||||
newFocusProvinceId = after.focusProvinceId,
|
||||
newName = optionalDiff(before.name, after.name)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package net.eagle0.eagle.library.util
|
||||
|
||||
import net.eagle0.eagle.{FactionId, ProvinceId}
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.internal.province.Province
|
||||
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
|
||||
import net.eagle0.eagle.library.util.ProvinceDistances.{closestProvinceOption, distance, ProvinceAndDistance}
|
||||
|
||||
/** Legacy proto-based province distance utilities. Use ProvinceDistances for protoless code. */
|
||||
object LegacyProvinceDistances {
|
||||
|
||||
def distanceThroughFriendliesOption(
|
||||
p1: ProvinceId,
|
||||
p2: ProvinceId,
|
||||
fid: FactionId,
|
||||
gs: GameState
|
||||
): Option[Int] = distance(p1, p2, LegacyFactionUtils.ownedNeighbors(gs, fid))
|
||||
|
||||
def closestProvinceThroughFriendliesOption(
|
||||
to: ProvinceId,
|
||||
candidates: Vector[ProvinceId],
|
||||
fid: FactionId,
|
||||
gs: GameState
|
||||
): Option[ProvinceAndDistance] =
|
||||
closestProvinceOption(
|
||||
to,
|
||||
candidates.filter(pid => gs.provinces(pid).rulingFactionId.contains(fid)),
|
||||
LegacyFactionUtils.ownedNeighbors(gs, fid)
|
||||
)
|
||||
|
||||
def closestNeighborToFaction(
|
||||
startingProvinceId: ProvinceId,
|
||||
factionId: FactionId,
|
||||
provinces: Map[ProvinceId, Province]
|
||||
): Option[ProvinceId] = {
|
||||
val factionProvinceIds = provinces.values
|
||||
.filter(_.rulingFactionId.contains(factionId))
|
||||
.map(_.id)
|
||||
.toVector
|
||||
|
||||
if factionProvinceIds.isEmpty then None
|
||||
else
|
||||
provinces(startingProvinceId).neighbors
|
||||
.map(_.provinceId)
|
||||
.toVector
|
||||
.minByOption { neighborPid =>
|
||||
closestProvinceOption(
|
||||
to = neighborPid,
|
||||
candidates = factionProvinceIds,
|
||||
eligibleNeighbors = pid =>
|
||||
provinces
|
||||
.get(pid)
|
||||
.map(_.neighbors.map(_.provinceId).toVector)
|
||||
.getOrElse(Vector())
|
||||
).map(_.distance).getOrElse(Int.MaxValue)
|
||||
}
|
||||
end if
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,7 @@ package net.eagle0.eagle.library.util
|
||||
import scala.annotation.tailrec
|
||||
|
||||
import net.eagle0.eagle.{FactionId, ProvinceId}
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.internal.province.Province
|
||||
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
|
||||
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
|
||||
import net.eagle0.eagle.model.state.province.ProvinceT
|
||||
|
||||
object ProvinceDistances {
|
||||
@@ -14,8 +12,8 @@ object ProvinceDistances {
|
||||
p1: ProvinceId,
|
||||
p2: ProvinceId,
|
||||
fid: FactionId,
|
||||
gs: GameState
|
||||
): Option[Int] = distance(p1, p2, LegacyFactionUtils.ownedNeighbors(gs, fid))
|
||||
provinces: Map[ProvinceId, ProvinceT]
|
||||
): Option[Int] = distance(p1, p2, FactionUtils.ownedNeighbors(provinces, fid))
|
||||
|
||||
def distance(
|
||||
p1: ProvinceId,
|
||||
@@ -34,18 +32,6 @@ object ProvinceDistances {
|
||||
else go(Vector(Set(p1)))
|
||||
}
|
||||
|
||||
def closestProvinceThroughFriendliesOption(
|
||||
to: ProvinceId,
|
||||
candidates: Vector[ProvinceId],
|
||||
fid: FactionId,
|
||||
gs: GameState
|
||||
): Option[ProvinceAndDistance] =
|
||||
closestProvinceOption(
|
||||
to,
|
||||
candidates.filter(pid => gs.provinces(pid).rulingFactionId.contains(fid)),
|
||||
LegacyFactionUtils.ownedNeighbors(gs, fid)
|
||||
)
|
||||
|
||||
def closestProvinceOption(
|
||||
to: ProvinceId,
|
||||
candidates: Vector[ProvinceId],
|
||||
@@ -80,40 +66,11 @@ object ProvinceDistances {
|
||||
}.toMap.get
|
||||
}
|
||||
|
||||
def closestNeighborToFaction(
|
||||
startingProvinceId: ProvinceId,
|
||||
factionId: FactionId,
|
||||
provinces: Map[ProvinceId, Province]
|
||||
): Option[ProvinceId] = {
|
||||
val factionProvinceIds = provinces.values
|
||||
.filter(_.rulingFactionId.contains(factionId))
|
||||
.map(_.id)
|
||||
.toVector
|
||||
|
||||
if factionProvinceIds.isEmpty then None
|
||||
else
|
||||
provinces(startingProvinceId).neighbors
|
||||
.map(_.provinceId)
|
||||
.toVector
|
||||
.minByOption { neighborPid =>
|
||||
closestProvinceOption(
|
||||
to = neighborPid,
|
||||
candidates = factionProvinceIds,
|
||||
eligibleNeighbors = pid =>
|
||||
provinces
|
||||
.get(pid)
|
||||
.map(_.neighbors.map(_.provinceId).toVector)
|
||||
.getOrElse(Vector())
|
||||
).map(_.distance).getOrElse(Int.MaxValue)
|
||||
}
|
||||
end if
|
||||
}
|
||||
|
||||
def closestNeighborToFaction(
|
||||
startingProvinceId: ProvinceId,
|
||||
factionId: FactionId,
|
||||
provinces: Map[ProvinceId, ProvinceT]
|
||||
)(using DummyImplicit): Option[ProvinceId] = {
|
||||
): Option[ProvinceId] = {
|
||||
val factionProvinceIds = provinces.values
|
||||
.filter(_.rulingFactionId.contains(factionId))
|
||||
.map(_.id)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
load("@rules_scala//scala:scala.bzl", "scala_library")
|
||||
|
||||
scala_library(
|
||||
name = "battalion_suitability",
|
||||
srcs = ["BattalionSuitability.scala"],
|
||||
visibility = [
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/availability:__pkg__",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:__pkg__",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:__pkg__",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters:__pkg__",
|
||||
"//src/test/scala/net/eagle0/eagle/library/util:__pkg__",
|
||||
"//src/test/scala/net/eagle0/eagle/library/util/battalion_suitability:__pkg__",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/hero",
|
||||
],
|
||||
)
|
||||
|
||||
scala_library(
|
||||
name = "legacy_battalion_suitability",
|
||||
srcs = ["LegacyBattalionSuitability.scala"],
|
||||
visibility = [
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/availability:__pkg__",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:__pkg__",
|
||||
"//src/test/scala/net/eagle0/eagle/library/util:__pkg__",
|
||||
"//src/test/scala/net/eagle0/eagle/library/util/battalion_suitability:__pkg__",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api/command/util:appropriate_battalions_scala_proto",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/hero",
|
||||
],
|
||||
)
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package net.eagle0.eagle.library.util.battalion_suitability
|
||||
|
||||
import net.eagle0.eagle.{BattalionId, HeroId}
|
||||
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
|
||||
|
||||
enum SuitabilityLevel:
|
||||
case Optimal, Suboptimal, Restrictive
|
||||
|
||||
case class BattalionIdWithSuitability(
|
||||
battalionId: BattalionId,
|
||||
level: SuitabilityLevel
|
||||
)
|
||||
|
||||
case class SuitableBattalions(
|
||||
battalionIdsWithSuitability: Vector[BattalionIdWithSuitability]
|
||||
)
|
||||
|
||||
object BattalionSuitability {
|
||||
def suitabilityLevel(h: HeroT, bt: BattalionType): SuitabilityLevel =
|
||||
h.profession match {
|
||||
case Mage | Necromancer =>
|
||||
if !bt.allowsCasting then SuitabilityLevel.Restrictive
|
||||
else if bt.alwaysArcheryCapable then SuitabilityLevel.Suboptimal
|
||||
else SuitabilityLevel.Optimal
|
||||
|
||||
case Engineer =>
|
||||
if bt.alwaysArcheryCapable then SuitabilityLevel.Suboptimal
|
||||
else SuitabilityLevel.Optimal
|
||||
|
||||
case Ranger =>
|
||||
if !bt.allowsStealth then SuitabilityLevel.Restrictive
|
||||
else SuitabilityLevel.Optimal
|
||||
|
||||
case _ => SuitabilityLevel.Optimal
|
||||
}
|
||||
|
||||
def suitableBattalions(
|
||||
h: HeroT,
|
||||
bs: Vector[BattalionT],
|
||||
bts: Vector[BattalionType]
|
||||
): SuitableBattalions =
|
||||
SuitableBattalions(
|
||||
battalionIdsWithSuitability = bs.map(b =>
|
||||
BattalionIdWithSuitability(
|
||||
battalionId = b.id,
|
||||
level = BattalionSuitability
|
||||
.suitabilityLevel(h, bts.find(_.typeId == b.typeId).get)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def suitableBattalionsForHeroes(
|
||||
hs: Vector[HeroT],
|
||||
bs: Vector[BattalionT],
|
||||
bts: Vector[BattalionType]
|
||||
): Map[HeroId, SuitableBattalions] =
|
||||
hs.map(h => h.id -> suitableBattalions(h, bs, bts)).toMap
|
||||
}
|
||||
+16
-16
@@ -1,4 +1,4 @@
|
||||
package net.eagle0.eagle.library.util
|
||||
package net.eagle0.eagle.library.util.battalion_suitability
|
||||
|
||||
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.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.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.HeroId
|
||||
|
||||
object BattalionSuitability {
|
||||
def suitabilityLevel(h: Hero, bt: BattalionType): SuitabilityLevel =
|
||||
object LegacyBattalionSuitability {
|
||||
def suitabilityLevel(h: HeroT, 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 BattalionSuitability {
|
||||
}
|
||||
|
||||
def suitableBattalions(
|
||||
h: Hero,
|
||||
bs: Vector[Battalion],
|
||||
h: HeroT,
|
||||
bs: Vector[BattalionT],
|
||||
bts: Vector[BattalionType]
|
||||
): SuitableBattalions =
|
||||
SuitableBattalions(
|
||||
battalionIdsWithSuitability = bs.map(b =>
|
||||
BattalionIdWithSuitability(
|
||||
battalionId = b.id,
|
||||
level = BattalionSuitability
|
||||
.suitabilityLevel(h, bts.find(_.typeId == b.`type`).get)
|
||||
level = LegacyBattalionSuitability
|
||||
.suitabilityLevel(h, bts.find(_.typeId == b.typeId).get)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def suitableBattalionsForHeroes(
|
||||
hs: Vector[Hero],
|
||||
bs: Vector[Battalion],
|
||||
hs: Vector[HeroT],
|
||||
bs: Vector[BattalionT],
|
||||
bts: Vector[BattalionType]
|
||||
): Map[HeroId, SuitableBattalions] =
|
||||
hs.map(h => h.id -> suitableBattalions(h, bs, bts)).toMap
|
||||
@@ -0,0 +1,39 @@
|
||||
load("@rules_scala//scala:scala.bzl", "scala_library")
|
||||
|
||||
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/scala/net/eagle0/eagle/model/state:battalion_type",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
|
||||
],
|
||||
)
|
||||
|
||||
scala_library(
|
||||
name = "legacy_battalion_type_finder",
|
||||
srcs = ["LegacyBattalionTypeFinder.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",
|
||||
],
|
||||
)
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package net.eagle0.eagle.library.util.battalion_type_finder
|
||||
|
||||
import net.eagle0.eagle.model.state.{BattalionType, BattalionTypeId}
|
||||
|
||||
object BattalionTypeFinder {
|
||||
def battalionType(
|
||||
battalionTypeId: BattalionTypeId,
|
||||
battalionTypes: Vector[BattalionType]
|
||||
): BattalionType =
|
||||
battalionTypes.find(_.typeId == battalionTypeId).get
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package net.eagle0.eagle.library.util
|
||||
package net.eagle0.eagle.library.util.battalion_type_finder
|
||||
|
||||
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 BattalionTypeFinder {
|
||||
object LegacyBattalionTypeFinder {
|
||||
def battalionType(
|
||||
battalionTypeId: BattalionTypeId,
|
||||
battalionTypes: Vector[BattalionTypeProto]
|
||||
+3
-3
@@ -4,17 +4,17 @@ import net.eagle0.eagle.{FactionId, HeroId}
|
||||
import net.eagle0.eagle.api.available_command.{AvailableCommand, DiplomacyAvailableCommand}
|
||||
import net.eagle0.eagle.api.command.util.diplomacy_option.{AllianceOption, DiplomacyOption}
|
||||
import net.eagle0.eagle.api.selected_command.DiplomacySelectedCommand
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.ofType
|
||||
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
|
||||
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
|
||||
import net.eagle0.eagle.library.util.CommandSelection
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
|
||||
object AllianceOfferCommandSelector {
|
||||
private def usableHeroIds(
|
||||
hids: Vector[HeroId],
|
||||
gameState: GameState
|
||||
): Vector[HeroId] =
|
||||
hids.filterNot(LegacyFactionUtils.isFactionLeader(_, gameState))
|
||||
hids.filterNot(FactionUtils.isFactionLeader(_, gameState.factions.values.toVector))
|
||||
|
||||
private def allianceOptionWithTarget(
|
||||
options: Vector[DiplomacyOption],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user