mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 00:35:41 +00:00
Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f074c7ac73 | ||
|
|
198e6ed3c0 | ||
|
|
affd0a8180 | ||
|
|
a92a878465 | ||
|
|
10696325e9 | ||
|
|
88273937da | ||
|
|
e472ab4df2 | ||
|
|
11110a5f88 | ||
|
|
d125394028 | ||
|
|
99037c7c3a | ||
|
|
c60d4b80f2 | ||
|
|
041514bd02 | ||
|
|
c410f790ff | ||
|
|
b4d19e7b65 | ||
|
|
9e8169df3e | ||
|
|
f92f400f28 | ||
|
|
28d7da9634 | ||
|
|
79aac7c9ce | ||
|
|
a530a219f1 | ||
|
|
335baf96dc | ||
|
|
584ccbadcb | ||
|
|
06f6ba8340 | ||
|
|
bfe5befff2 | ||
|
|
521dde9503 | ||
|
|
85eccb97f0 | ||
|
|
e270d27a4a | ||
|
|
d9893edcb1 | ||
|
|
5d4719ea19 | ||
|
|
e63a8489b6 | ||
|
|
9b190254a4 | ||
|
|
eb2677bd84 | ||
|
|
23d2cb3968 | ||
|
|
c66263cd56 | ||
|
|
0ba3e0e5ae | ||
|
|
860d0f3c5f | ||
|
|
13f79c37bc | ||
|
|
87cd58f7a4 | ||
|
|
e739e18d77 | ||
|
|
c03daf100c | ||
|
|
6519850ec3 | ||
|
|
df785f557b | ||
|
|
224dc3bc13 | ||
|
|
5f3095dbd3 | ||
|
|
fb12810105 | ||
|
|
fe2a5b0869 | ||
|
|
3f00ba4a03 | ||
|
|
3dd06c0aba | ||
|
|
d95f65d9b4 | ||
|
|
a7c1146c41 | ||
|
|
56a5c27d42 | ||
|
|
1d707ba5f0 | ||
|
|
e4f3df7e26 | ||
|
|
d68302d2b5 | ||
|
|
4a7559c25d | ||
|
|
1eb253d866 | ||
|
|
c770a3693b | ||
|
|
99ad9fb49e | ||
|
|
d80a151303 | ||
|
|
b9f48e14be | ||
|
|
32c62357eb | ||
|
|
a4bf652bf7 | ||
|
|
31458c2a98 | ||
|
|
dabf309e6d | ||
|
|
f3bcf44cb3 | ||
|
|
6f486cbd32 | ||
|
|
bb07a90c0d | ||
|
|
30c3093829 | ||
|
|
d723a7e026 | ||
|
|
7c8453b6c0 | ||
|
|
7e87ca792e | ||
|
|
89db5be805 |
@@ -0,0 +1,175 @@
|
||||
name: Auth Service Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 'src/main/go/net/eagle0/authservice/**'
|
||||
- 'src/main/protobuf/net/eagle0/eagle/api/auth.proto'
|
||||
- 'src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto'
|
||||
- 'ci/BUILD.bazel'
|
||||
- '.github/workflows/auth_build.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push_images:
|
||||
description: 'Push images to container registry'
|
||||
required: true
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-auth:
|
||||
runs-on: self-hosted
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-auth.outputs.image_tag }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Build Auth Server Docker image
|
||||
id: build-auth
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
# Build auth server image (Go binary has explicit goos/goarch in BUILD.bazel)
|
||||
bazel build //ci:auth_server_image
|
||||
|
||||
# Save the resolved path before any other bazel command changes bazel-bin symlink
|
||||
IMAGE_PATH=$(readlink -f bazel-bin/ci/auth_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 Auth image to DO registry
|
||||
id: push-auth
|
||||
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
|
||||
|
||||
AUTH_IMAGE="${{ steps.build-auth.outputs.image_path }}"
|
||||
echo "Using Auth image: $AUTH_IMAGE"
|
||||
|
||||
if [ -z "$AUTH_IMAGE" ] || [ ! -d "$AUTH_IMAGE" ]; then
|
||||
echo "ERROR: Auth image not found at: $AUTH_IMAGE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build the push target to get crane in runfiles
|
||||
bazel build //ci:auth_server_push
|
||||
|
||||
# Use crane directly for push
|
||||
CRANE="bazel-bin/ci/push_auth_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/auth-server:${GIT_SHA}"
|
||||
echo "Pushing auth image: $IMAGE_TAG"
|
||||
$CRANE push "$AUTH_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/auth-server:latest"
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-auth]
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
environment: production
|
||||
env:
|
||||
AUTH_IMAGE: ${{ needs.build-auth.outputs.image_tag }}
|
||||
DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }}
|
||||
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy auth service to production
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.DO_DROPLET_IP }}
|
||||
username: deploy
|
||||
key: ${{ secrets.DO_SSH_KEY }}
|
||||
script_stop: true
|
||||
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,JWT_PRIVATE_KEY
|
||||
script: |
|
||||
set -x
|
||||
cd /opt/eagle0
|
||||
|
||||
# Update AUTH_IMAGE in .env file (preserve other vars)
|
||||
if [ -f .env ]; then
|
||||
# Remove old AUTH_IMAGE line and add new one
|
||||
grep -v '^AUTH_IMAGE=' .env > .env.tmp || true
|
||||
echo "AUTH_IMAGE=${AUTH_IMAGE}" >> .env.tmp
|
||||
# Also update OAuth credentials
|
||||
grep -v '^DISCORD_CLIENT_ID=' .env.tmp > .env.tmp2 || true
|
||||
echo "DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID:-}" >> .env.tmp2
|
||||
grep -v '^DISCORD_CLIENT_SECRET=' .env.tmp2 > .env.tmp3 || true
|
||||
echo "DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET:-}" >> .env.tmp3
|
||||
grep -v '^GOOGLE_CLIENT_ID=' .env.tmp3 > .env.tmp4 || true
|
||||
echo "GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}" >> .env.tmp4
|
||||
grep -v '^GOOGLE_CLIENT_SECRET=' .env.tmp4 > .env.tmp5 || true
|
||||
echo "GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}" >> .env.tmp5
|
||||
# Update JWT private key (JWK format for auth service bootstrap)
|
||||
grep -v '^JWT_PRIVATE_KEY=' .env.tmp5 > .env || true
|
||||
echo "JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY:-}" >> .env
|
||||
rm -f .env.tmp .env.tmp2 .env.tmp3 .env.tmp4 .env.tmp5
|
||||
chmod 600 .env
|
||||
else
|
||||
echo "ERROR: .env file not found. Run main deploy first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Login to registry
|
||||
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
|
||||
|
||||
echo "Deploying auth service: $AUTH_IMAGE"
|
||||
|
||||
# Use crane to pull image
|
||||
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
|
||||
|
||||
echo "Pulling Auth image with crane..."
|
||||
./crane pull "${AUTH_IMAGE}" auth.tar || { echo "ERROR: Failed to pull auth image"; exit 1; }
|
||||
echo "Loading Auth image into Docker..."
|
||||
docker load -i auth.tar
|
||||
rm auth.tar
|
||||
rm ./crane
|
||||
|
||||
# Only recreate the auth container (not eagle, shardok, etc.)
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate auth
|
||||
|
||||
# Wait for health check
|
||||
sleep 5
|
||||
docker compose -f docker-compose.prod.yml ps auth
|
||||
|
||||
# Verify container is using correct image
|
||||
echo "=== Verifying auth container image ==="
|
||||
docker compose -f docker-compose.prod.yml images auth
|
||||
|
||||
# Cleanup old images
|
||||
docker image prune -f
|
||||
@@ -8,12 +8,22 @@ on:
|
||||
required: true
|
||||
default: 'v2'
|
||||
type: string
|
||||
architecture:
|
||||
description: 'Target architecture'
|
||||
required: true
|
||||
default: 'amd64'
|
||||
type: choice
|
||||
options:
|
||||
- amd64
|
||||
- arm64
|
||||
- both
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-sysroot:
|
||||
build-sysroot-amd64:
|
||||
if: ${{ inputs.architecture == 'amd64' || inputs.architecture == 'both' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -25,7 +35,7 @@ jobs:
|
||||
- name: Upload sysroot artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ubuntu-noble-sysroot
|
||||
name: ubuntu-noble-sysroot-amd64
|
||||
path: tools/sysroot/output/
|
||||
|
||||
- name: Install AWS CLI
|
||||
@@ -41,7 +51,7 @@ jobs:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: |
|
||||
# Upload sysroot tarball to DO Spaces (using eagle0-windows bucket, same as other workflows)
|
||||
# Upload sysroot tarball to DO Spaces
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.tar.xz \
|
||||
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
@@ -54,7 +64,7 @@ jobs:
|
||||
--acl public-read
|
||||
|
||||
echo ""
|
||||
echo "=== Sysroot uploaded ==="
|
||||
echo "=== AMD64 Sysroot uploaded ==="
|
||||
echo "URL: https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz"
|
||||
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)"
|
||||
echo ""
|
||||
@@ -64,3 +74,64 @@ jobs:
|
||||
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)\","
|
||||
echo " urls = [\"https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
|
||||
echo ")"
|
||||
|
||||
build-sysroot-arm64:
|
||||
if: ${{ inputs.architecture == 'arm64' || inputs.architecture == 'both' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU for ARM64 emulation
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build ARM64 sysroot
|
||||
run: ./tools/sysroot/build_sysroot_arm64.sh
|
||||
|
||||
- name: Upload sysroot artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ubuntu-noble-sysroot-arm64
|
||||
path: tools/sysroot/output/
|
||||
|
||||
- name: Install AWS CLI
|
||||
run: |
|
||||
if ! command -v aws &> /dev/null; then
|
||||
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
|
||||
unzip -q awscliv2.zip
|
||||
sudo ./aws/install
|
||||
fi
|
||||
|
||||
- name: Upload to DigitalOcean Spaces
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: |
|
||||
# Upload sysroot tarball to DO Spaces
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_arm64_sysroot.tar.xz \
|
||||
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
--acl public-read
|
||||
|
||||
# Upload sha256 file
|
||||
aws s3 cp tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256 \
|
||||
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.sha256 \
|
||||
--endpoint-url https://sfo3.digitaloceanspaces.com \
|
||||
--acl public-read
|
||||
|
||||
echo ""
|
||||
echo "=== ARM64 Sysroot uploaded ==="
|
||||
echo "URL: https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz"
|
||||
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256)"
|
||||
echo ""
|
||||
echo "Update MODULE.bazel with:"
|
||||
echo "sysroot("
|
||||
echo " name = \"linux_sysroot_arm64\","
|
||||
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256)\","
|
||||
echo " urls = [\"https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz\"],"
|
||||
echo ")"
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Deploy OAuth Relay Worker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'cloudflare/oauth-relay/**'
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy to Cloudflare Workers
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy Worker
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
workingDirectory: cloudflare/oauth-relay
|
||||
@@ -11,6 +11,8 @@ on:
|
||||
- 'src/main/resources/**'
|
||||
- 'ci/BUILD.bazel'
|
||||
- 'MODULE.bazel'
|
||||
- 'docker-compose.prod.yml'
|
||||
- 'nginx/**'
|
||||
- '.github/workflows/docker_build.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -433,6 +435,11 @@ jobs:
|
||||
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 }}
|
||||
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
|
||||
DISCORD_CLIENT_ID: ${{ secrets.DISCORD_CLIENT_ID }}
|
||||
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
|
||||
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
|
||||
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -453,19 +460,35 @@ jobs:
|
||||
username: deploy
|
||||
key: ${{ secrets.DO_SSH_KEY }}
|
||||
script_stop: true
|
||||
envs: EAGLE_IMAGE,SHARDOK_IMAGE,ADMIN_IMAGE,JFR_SIDECAR_IMAGE,OPENAI_API_KEY,GPT_MODEL_NAME,EAGLE_ENABLE_S3,DO_SPACES_ACCESS_KEY,DO_SPACES_SECRET_KEY
|
||||
envs: EAGLE_IMAGE,SHARDOK_IMAGE,ADMIN_IMAGE,JFR_SIDECAR_IMAGE,OPENAI_API_KEY,GPT_MODEL_NAME,EAGLE_ENABLE_S3,DO_SPACES_ACCESS_KEY,DO_SPACES_SECRET_KEY,JWT_PRIVATE_KEY,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET
|
||||
script: |
|
||||
set -x
|
||||
cd /opt/eagle0
|
||||
|
||||
# Write env vars to .env file for docker-compose
|
||||
# Preserve AUTH_IMAGE if it exists (managed by auth_build.yml)
|
||||
EXISTING_AUTH_IMAGE=""
|
||||
if [ -f .env ]; then
|
||||
EXISTING_AUTH_IMAGE=$(grep '^AUTH_IMAGE=' .env | cut -d= -f2- || true)
|
||||
fi
|
||||
|
||||
rm -f .env 2>/dev/null || true
|
||||
cat > .env << EOF
|
||||
EAGLE_IMAGE=${EAGLE_IMAGE}
|
||||
SHARDOK_IMAGE=${SHARDOK_IMAGE}
|
||||
ADMIN_IMAGE=${ADMIN_IMAGE}
|
||||
JFR_SIDECAR_IMAGE=${JFR_SIDECAR_IMAGE}
|
||||
AUTH_IMAGE=${EXISTING_AUTH_IMAGE:-registry.digitalocean.com/eagle0/auth-server:latest}
|
||||
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:-}
|
||||
JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY:-}
|
||||
DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID:-}
|
||||
DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET:-}
|
||||
GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
||||
GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
||||
EOF
|
||||
chmod 600 .env
|
||||
|
||||
@@ -473,6 +496,7 @@ jobs:
|
||||
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)
|
||||
# Note: AUTH_IMAGE is managed separately by auth_build.yml
|
||||
echo "Using images: $EAGLE_IMAGE, $SHARDOK_IMAGE, $ADMIN_IMAGE, $JFR_SIDECAR_IMAGE"
|
||||
|
||||
# Use crane to pull images (handles OCI format correctly) then load into Docker
|
||||
@@ -514,12 +538,19 @@ jobs:
|
||||
|
||||
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
|
||||
# Recreate only the services we're updating (not auth - managed by auth_build.yml)
|
||||
# This prevents restarting auth service during every Eagle deploy
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate eagle shardok admin jfr-sidecar
|
||||
|
||||
# Ensure auth is running (but don't force-recreate it)
|
||||
docker compose -f docker-compose.prod.yml up -d auth
|
||||
|
||||
# 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
|
||||
docker compose -f docker-compose.prod.yml up -d --force-recreate nginx
|
||||
|
||||
# Cleanup orphaned containers
|
||||
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
||||
|
||||
# Wait for health checks
|
||||
sleep 10
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
name: Shardok ARM64 Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 'src/main/cpp/**'
|
||||
- 'src/main/protobuf/net/eagle0/shardok/**'
|
||||
- 'src/main/protobuf/net/eagle0/common/**'
|
||||
- 'src/main/resources/net/eagle0/shardok/**'
|
||||
- 'ci/BUILD.bazel'
|
||||
- 'MODULE.bazel'
|
||||
- '.github/workflows/shardok_arm64_build.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
push_images:
|
||||
description: 'Push images to container registry'
|
||||
required: true
|
||||
default: 'true'
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-shardok-arm64:
|
||||
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 ARM64 binary (cross-compile for Linux ARM64)
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
echo "=== Building shardok-server binary for linux-aarch64 ==="
|
||||
bazel build \
|
||||
--platforms=//:linux_arm64 \
|
||||
--extra_toolchains=@llvm_toolchain_linux_arm64//:all \
|
||||
//src/main/cpp/net/eagle0/shardok:shardok-server
|
||||
|
||||
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
|
||||
|
||||
# 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)"
|
||||
# Check if it's ARM64 (e_machine = 0xB7 = 183 for aarch64)
|
||||
E_MACHINE=$(od -An -j18 -N2 -tx2 "$LINUX_BIN" | tr -d ' ')
|
||||
echo "ELF e_machine: $E_MACHINE"
|
||||
if [ "$E_MACHINE" = "b700" ]; then
|
||||
echo "SUCCESS: Binary is ARM64 (aarch64)"
|
||||
else
|
||||
echo "WARNING: Binary e_machine is $E_MACHINE (expected b700 for aarch64)"
|
||||
fi
|
||||
elif [ "$MAGIC" = "cfaeedfe" ] || [ "$MAGIC" = "cffaedfe" ]; then
|
||||
echo "ERROR: Binary is Mach-O format (macOS) - cross-compilation failed!"
|
||||
exit 1
|
||||
else
|
||||
echo "WARNING: Unknown binary format: $MAGIC"
|
||||
file "$LINUX_BIN" || true
|
||||
fi
|
||||
|
||||
- name: Build Shardok ARM64 Docker image
|
||||
id: build-shardok
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
bazel build \
|
||||
--platforms=//:linux_arm64 \
|
||||
--extra_toolchains=@llvm_toolchain_linux_arm64//:all \
|
||||
//ci:shardok_server_image_arm64
|
||||
|
||||
IMAGE_PATH=$(readlink -f bazel-bin/ci/shardok_server_image_arm64)
|
||||
echo "Image path: $IMAGE_PATH"
|
||||
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
# Verify the binary inside the tar layer is ARM64 ELF
|
||||
echo "=== Verifying binary in image tar ==="
|
||||
BINARY_TAR="bazel-bin/ci/shardok_binary_layer_arm64.tar"
|
||||
if [ -f "$BINARY_TAR" ]; then
|
||||
echo "Checking binary in $BINARY_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!"
|
||||
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')
|
||||
env:
|
||||
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
run: |
|
||||
AUTH=$(echo -n "${DO_REGISTRY_TOKEN}:${DO_REGISTRY_TOKEN}" | base64)
|
||||
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
|
||||
|
||||
- name: Push Shardok ARM64 image to DigitalOcean
|
||||
id: push-shardok
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
run: |
|
||||
set -ex
|
||||
|
||||
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
|
||||
|
||||
# Build a push target to get crane in runfiles
|
||||
bazel build //ci:eagle_server_push
|
||||
|
||||
# Find the Darwin crane binary
|
||||
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
|
||||
CRANE=$(find "$RUNFILES" -name crane 2>/dev/null | head -1)
|
||||
fi
|
||||
if [ -z "$CRANE" ] || [ ! -e "$CRANE" ]; then
|
||||
echo "ERROR: crane not found"
|
||||
exit 1
|
||||
fi
|
||||
echo "Using crane: $CRANE"
|
||||
|
||||
# Push with SHA tag
|
||||
GIT_SHA=$(git rev-parse --short=8 HEAD)
|
||||
IMAGE_TAG="registry.digitalocean.com/eagle0/shardok-server-arm64:${GIT_SHA}"
|
||||
echo "Pushing shardok ARM64 image: $IMAGE_TAG"
|
||||
$CRANE push "$CROSS_IMAGE" "$IMAGE_TAG"
|
||||
|
||||
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Also update :latest tag for convenience
|
||||
echo "Copying to :latest tag"
|
||||
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/shardok-server-arm64:latest"
|
||||
|
||||
echo "=== Push complete ==="
|
||||
echo "Image: $IMAGE_TAG"
|
||||
echo "Also tagged as: registry.digitalocean.com/eagle0/shardok-server-arm64:latest"
|
||||
@@ -12,6 +12,15 @@ platform(
|
||||
],
|
||||
)
|
||||
|
||||
# Platform for cross-compiling to Linux ARM64
|
||||
platform(
|
||||
name = "linux_arm64",
|
||||
constraint_values = [
|
||||
"@platforms//os:linux",
|
||||
"@platforms//cpu:aarch64",
|
||||
],
|
||||
)
|
||||
|
||||
gazelle(name = "gazelle")
|
||||
|
||||
# gazelle:proto file
|
||||
|
||||
+56
-25
@@ -70,43 +70,73 @@ When migrating a file:
|
||||
### Fully Protoless
|
||||
|
||||
- [x] `AIClientUtils` - has protoless overloads (`takenHeroIdsForMarchTowardFocus`, `mostPowerfulHeroes`)
|
||||
- [x] `AttackCommandChooser` - uses Scala `GameState` and `BattalionViewC` (Scala view type)
|
||||
- [x] `BattalionPower` - has `estimatedPower(BattalionViewC)` for recon data with optional stats
|
||||
- [x] `CommandChoiceHelpers` - fully protoless, uses Scala `GameState` throughout
|
||||
- [x] `ProvinceGoldSurplusCalculator` - fully protoless (callers use converters)
|
||||
- [x] `HeroSelector` - fully protoless (removed dead `minimallyFatiguedHeroesProto`)
|
||||
|
||||
### Blocked (still uses proto GameState)
|
||||
### AI Layer ✅ COMPLETE
|
||||
|
||||
- [ ] `CommandChoiceHelpers` - main target, uses proto GameState extensively
|
||||
- Depends on many Legacy* utils
|
||||
- Central hub called by many command selectors
|
||||
- [ ] `AttackDecisionCommandChooser` - uses proto GameState, converts internally
|
||||
- [ ] `CommandChooser` - trait uses proto GameState in signature
|
||||
- [ ] `FulfillQuestsCommandSelector` - takes proto, converts to native immediately
|
||||
- Called by `MidGameAIClient` which uses proto GameState
|
||||
All AI and command chooser code is now fully protoless:
|
||||
- [x] `AttackDecisionCommandChooser` - uses Scala GameState
|
||||
- [x] `CommandChooser` - trait uses Scala GameState
|
||||
- [x] `FulfillQuestsCommandSelector` - uses Scala GameState
|
||||
- [x] `MidGameAIClient` - uses Scala GameState internally
|
||||
|
||||
### Still Using Proto GameState (Boundary Code)
|
||||
|
||||
These files use proto GameState because they're at system boundaries:
|
||||
|
||||
**View Filters (client projection):**
|
||||
- `view_filters/GameStateViewFilter` - has Scala overload, but converts internally (sub-filters still need proto)
|
||||
- `view_filters/ProvinceViewFilter` - has Scala overloads for some methods
|
||||
- `view_filters/FactionViewFilter`, `HeroViewFilter`, `BattalionNameFilter`, `BattleFilter` - still proto-only
|
||||
|
||||
**Legacy Utilities (to be deprecated):**
|
||||
- `LegacyProvinceDistances`, `LegacyFactionUtils`, `LegacyHeroUtils`, etc.
|
||||
- Used by code that still needs proto GameState
|
||||
|
||||
**Persistence/Action System:**
|
||||
- `ActionResultTApplier`, `ActionResultProtoApplier` - apply results to proto state
|
||||
- `ActionWithResultingState` - caches both proto and Scala state
|
||||
|
||||
**Shardok Interface (gRPC boundary):**
|
||||
- `ShardokInterfaceGrpcClient`, `ShardokInterfaceProxy` - must use proto for C++ communication
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Phase 1: CommandChoiceHelpers Migration
|
||||
### Phase 1-3: AI Layer ✅ COMPLETE
|
||||
|
||||
The main blocker is `CommandChoiceHelpers.scala` which uses proto `GameState` extensively. Strategy:
|
||||
The entire AI decision-making layer is now protoless.
|
||||
|
||||
1. **Add Scala overloads** to `CommandChoiceHelpers` methods that currently take proto GameState
|
||||
2. **Update internal helpers** to use Scala types where possible
|
||||
3. **Migrate callers incrementally** - command selectors that are already protoless can switch to Scala overloads
|
||||
### Phase 4: View Filters (In Progress)
|
||||
|
||||
### Phase 2: CommandChooser Trait
|
||||
The view_filters package is being migrated. Progress:
|
||||
|
||||
Once CommandChoiceHelpers is protoless:
|
||||
**Completed:**
|
||||
- [x] `GameStateViewFilter` - added Scala GameState overload (converts internally for now)
|
||||
- [x] `ProvinceViewFilter` - already has Scala overloads for server-side views
|
||||
- [x] `HumanPlayerClientConnectionState` - updated to pass Scala GameState directly
|
||||
|
||||
1. Add Scala `GameState` overload to `CommandChooser.choose()` method
|
||||
2. Update implementations (`AttackDecisionCommandChooser`, etc.) to use Scala internally
|
||||
3. Eventually deprecate proto overloads
|
||||
**Still Using Proto:**
|
||||
- [ ] `HeroViewFilter` - needs Scala overload
|
||||
- [ ] `FactionViewFilter` - needs Scala overload
|
||||
- [ ] `BattalionNameFilter` - needs Scala overload
|
||||
- [ ] `BattleFilter` - needs Scala overload
|
||||
- [ ] `ActionResultFilter` - uses proto internally (boundary code)
|
||||
|
||||
### Phase 3: MidGameAIClient
|
||||
**Strategy:**
|
||||
1. Add Scala GameState overloads to view filter methods
|
||||
2. Update callers to pass Scala GameState where available
|
||||
3. Eventually deprecate proto versions
|
||||
|
||||
The top-level AI client still uses proto GameState. Once lower layers are protoless:
|
||||
### Phase 5: Legacy Utility Cleanup
|
||||
|
||||
1. Convert `MidGameAIClient` to use Scala GameState internally
|
||||
2. Only convert at the boundary when receiving from/sending to gRPC
|
||||
Remove Legacy* utilities by migrating remaining callers:
|
||||
1. Identify callers of each Legacy* util
|
||||
2. Update callers to use protoless versions
|
||||
3. Delete Legacy* files when no longer needed
|
||||
|
||||
## Key Files
|
||||
|
||||
@@ -123,6 +153,7 @@ The top-level AI client still uses proto GameState. Once lower layers are protol
|
||||
|
||||
## 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
|
||||
- The AI client code (`src/main/scala/net/eagle0/eagle/ai/`) is now fully protoless
|
||||
- Proto GameState is still needed at boundaries: persistence, gRPC to Shardok, and client view creation
|
||||
- `PerformUnaffiliatedHeroesAction` and the LLM pipeline use protoless `GameState`
|
||||
- Next focus: view_filters package to reduce proto usage in client view creation
|
||||
|
||||
+41
-5
@@ -57,25 +57,47 @@ llvm.toolchain(
|
||||
llvm_version = "20.1.2",
|
||||
)
|
||||
|
||||
# Linux sysroot for cross-compilation (Chromium's Debian sysroot)
|
||||
# Linux x86_64 sysroot for cross-compilation
|
||||
llvm.sysroot(
|
||||
name = "llvm_toolchain_linux",
|
||||
label = "@linux_sysroot//sysroot",
|
||||
targets = ["linux-x86_64"],
|
||||
)
|
||||
|
||||
use_repo(llvm, "llvm_toolchain", "llvm_toolchain_linux")
|
||||
# Cross-compilation toolchain (macOS -> Linux ARM64)
|
||||
llvm.toolchain(
|
||||
name = "llvm_toolchain_linux_arm64",
|
||||
llvm_version = "20.1.2",
|
||||
)
|
||||
|
||||
# Download the Linux sysroot (Ubuntu 24.04 Noble for C++23 support)
|
||||
# Linux ARM64 sysroot for cross-compilation
|
||||
llvm.sysroot(
|
||||
name = "llvm_toolchain_linux_arm64",
|
||||
label = "@linux_sysroot_arm64//sysroot",
|
||||
targets = ["linux-aarch64"],
|
||||
)
|
||||
|
||||
use_repo(llvm, "llvm_toolchain", "llvm_toolchain_linux", "llvm_toolchain_linux_arm64")
|
||||
|
||||
# Download the Linux sysroots (Ubuntu 24.04 Noble for C++23 support)
|
||||
# Built by: .github/workflows/build_sysroot.yml
|
||||
# To rebuild: Run the "Build Linux Sysroot" workflow with a new version, then update sha256 and URL
|
||||
sysroot = use_repo_rule("@toolchains_llvm//toolchain:sysroot.bzl", "sysroot")
|
||||
|
||||
# x86_64 sysroot
|
||||
sysroot(
|
||||
name = "linux_sysroot",
|
||||
sha256 = "a06475004fe8003ae7ccb4fe1d5511feb9b27cce4a8826eb1dfd686ed83f3dba",
|
||||
urls = ["https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/v3/ubuntu_noble_amd64_sysroot.tar.xz"],
|
||||
)
|
||||
|
||||
# ARM64 sysroot
|
||||
sysroot(
|
||||
name = "linux_sysroot_arm64",
|
||||
sha256 = "87469137737e09bc73855007dab835477eb10a7b3ce3f725f93f64e25747f3f9",
|
||||
urls = ["https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/v4/ubuntu_noble_arm64_sysroot.tar.xz"],
|
||||
)
|
||||
|
||||
#
|
||||
# Language Support - Go
|
||||
#
|
||||
@@ -94,6 +116,8 @@ use_repo(
|
||||
"com_github_aws_aws_sdk_go_v2_config",
|
||||
"com_github_aws_aws_sdk_go_v2_credentials",
|
||||
"com_github_aws_aws_sdk_go_v2_service_s3",
|
||||
"com_github_golang_jwt_jwt_v5",
|
||||
"com_github_google_uuid",
|
||||
"org_golang_google_grpc",
|
||||
"org_golang_google_protobuf",
|
||||
)
|
||||
@@ -142,7 +166,10 @@ oci.pull(
|
||||
oci.pull(
|
||||
name = "ubuntu_24_04",
|
||||
image = "docker.io/library/ubuntu",
|
||||
platforms = ["linux/amd64"],
|
||||
platforms = [
|
||||
"linux/amd64",
|
||||
"linux/arm64/v8",
|
||||
],
|
||||
tag = "24.04",
|
||||
)
|
||||
|
||||
@@ -153,7 +180,7 @@ oci.pull(
|
||||
platforms = ["linux/amd64"],
|
||||
tag = "3.21",
|
||||
)
|
||||
use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_17", "eclipse_temurin_17_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64")
|
||||
use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_17", "eclipse_temurin_17_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64", "ubuntu_24_04_linux_arm64_v8")
|
||||
|
||||
#
|
||||
# Java/Scala Dependencies
|
||||
@@ -273,6 +300,14 @@ http_file(
|
||||
executable = True,
|
||||
)
|
||||
|
||||
http_file(
|
||||
name = "busybox_aarch64",
|
||||
sha256 = "141adb1b625a6f44c4b114f76b4387b4ea4f7ab802b88eb40e0d2f6adcccb1c3",
|
||||
urls = ["https://busybox.net/downloads/binaries/1.35.0-aarch64-linux-musl/busybox"],
|
||||
downloaded_file_path = "busybox",
|
||||
executable = True,
|
||||
)
|
||||
|
||||
#
|
||||
# Toolchain Registration
|
||||
#
|
||||
@@ -286,5 +321,6 @@ register_toolchains(
|
||||
register_toolchains(
|
||||
"@llvm_toolchain//:all",
|
||||
"@llvm_toolchain_linux//:all",
|
||||
"@llvm_toolchain_linux_arm64//:all",
|
||||
dev_dependency = True,
|
||||
)
|
||||
|
||||
Generated
+18
-2
@@ -1293,7 +1293,7 @@
|
||||
"@@rules_oci~//oci:extensions.bzl%oci": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "FaY+7xb13bB3hmxqwAWaGp3Tf3Q4Nfdlr+F38CP5mcg=",
|
||||
"usagesDigest": "uqsqMpE+yaVuTByR2rIA2v1Vkm1JwwuN6nbXOo1W9G0=",
|
||||
"usagesDigest": "39yHQmifPoGf+JSYpnQSnJGugxbrFVge/+ENmUiqZ6M=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
"envVariables": {},
|
||||
@@ -1343,6 +1343,20 @@
|
||||
"bazel_tags": []
|
||||
}
|
||||
},
|
||||
"ubuntu_24_04_linux_arm64_v8": {
|
||||
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
|
||||
"ruleClassName": "oci_pull",
|
||||
"attributes": {
|
||||
"www_authenticate_challenges": {},
|
||||
"scheme": "https",
|
||||
"registry": "index.docker.io",
|
||||
"repository": "library/ubuntu",
|
||||
"identifier": "24.04",
|
||||
"platform": "linux/arm64/v8",
|
||||
"target_name": "ubuntu_24_04_linux_arm64_v8",
|
||||
"bazel_tags": []
|
||||
}
|
||||
},
|
||||
"ubuntu_24_04": {
|
||||
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
|
||||
"ruleClassName": "oci_alias",
|
||||
@@ -1354,7 +1368,8 @@
|
||||
"repository": "library/ubuntu",
|
||||
"identifier": "24.04",
|
||||
"platforms": {
|
||||
"@@platforms//cpu:x86_64": "@ubuntu_24_04_linux_amd64"
|
||||
"@@platforms//cpu:x86_64": "@ubuntu_24_04_linux_amd64",
|
||||
"@@platforms//cpu:arm64": "@ubuntu_24_04_linux_arm64_v8"
|
||||
},
|
||||
"bzlmod_repository": "ubuntu_24_04",
|
||||
"reproducible": true
|
||||
@@ -1528,6 +1543,7 @@
|
||||
"eclipse_temurin_17_linux_amd64",
|
||||
"ubuntu_24_04",
|
||||
"ubuntu_24_04_linux_amd64",
|
||||
"ubuntu_24_04_linux_arm64_v8",
|
||||
"alpine_linux",
|
||||
"alpine_linux_linux_amd64"
|
||||
],
|
||||
|
||||
+104
@@ -17,6 +17,18 @@ pkg_tar(
|
||||
},
|
||||
)
|
||||
|
||||
pkg_tar(
|
||||
name = "busybox_layer_arm64",
|
||||
srcs = ["@busybox_aarch64//file"],
|
||||
package_dir = "/usr/local/bin",
|
||||
remap_paths = {
|
||||
"file/busybox": "busybox",
|
||||
},
|
||||
symlinks = {
|
||||
"/usr/local/bin/nc": "busybox",
|
||||
},
|
||||
)
|
||||
|
||||
#
|
||||
# Eagle Server Docker Image
|
||||
#
|
||||
@@ -155,6 +167,53 @@ oci_push(
|
||||
repository = "registry.digitalocean.com/eagle0/shardok-server",
|
||||
)
|
||||
|
||||
#
|
||||
# Shardok Server ARM64 Docker Image (for Hetzner on-demand compute)
|
||||
#
|
||||
# Build: bazel build //ci:shardok_server_image_arm64 --platforms=//:linux_arm64 --extra_toolchains=@llvm_toolchain_linux_arm64//:all
|
||||
# Load: bazel run //ci:shardok_server_load_arm64
|
||||
# Push: bazel run //ci:shardok_server_push_arm64
|
||||
#
|
||||
|
||||
# Package the Shardok binary (ARM64 version - must be built with --platforms=//:linux_arm64)
|
||||
pkg_tar(
|
||||
name = "shardok_binary_layer_arm64",
|
||||
srcs = ["//src/main/cpp/net/eagle0/shardok:shardok-server"],
|
||||
package_dir = "/app",
|
||||
)
|
||||
|
||||
oci_image(
|
||||
name = "shardok_server_image_arm64",
|
||||
base = "@ubuntu_24_04_linux_arm64_v8",
|
||||
entrypoint = ["/app/shardok-server"],
|
||||
exposed_ports = [
|
||||
"40042/tcp",
|
||||
"40052/tcp",
|
||||
],
|
||||
tars = [
|
||||
# Note: busybox_layer_arm64 omitted - busybox.net has SSL issues
|
||||
# Health checks can use the shardok-server binary itself or be added later
|
||||
":shardok_binary_layer_arm64",
|
||||
":shardok_resources_layer",
|
||||
":shardok_maps_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
)
|
||||
|
||||
# Load into Docker locally (ARM64): bazel run //ci:shardok_server_load_arm64
|
||||
oci_load(
|
||||
name = "shardok_server_load_arm64",
|
||||
image = ":shardok_server_image_arm64",
|
||||
repo_tags = ["eagle0/shardok-server:latest-arm64"],
|
||||
)
|
||||
|
||||
# Push to DigitalOcean Container Registry (for Hetzner deployment)
|
||||
oci_push(
|
||||
name = "shardok_server_push_arm64",
|
||||
image = ":shardok_server_image_arm64",
|
||||
repository = "registry.digitalocean.com/eagle0/shardok-server-arm64",
|
||||
)
|
||||
|
||||
#
|
||||
# Admin Server Docker Image (Go)
|
||||
#
|
||||
@@ -237,3 +296,48 @@ oci_push(
|
||||
image = ":jfr_sidecar_image",
|
||||
repository = "registry.digitalocean.com/eagle0/jfr-sidecar",
|
||||
)
|
||||
|
||||
#
|
||||
# Auth Server Docker Image (Go)
|
||||
#
|
||||
# This is the external OAuth service that handles OAuth flows and JWT creation.
|
||||
# Build: bazel build //ci:auth_server_image
|
||||
# Load: bazel run //ci:auth_server_load
|
||||
# Push: bazel run //ci:auth_server_push
|
||||
#
|
||||
|
||||
# Package the Go auth binary (explicit Linux x86_64 target)
|
||||
pkg_tar(
|
||||
name = "auth_binary_layer",
|
||||
srcs = ["//src/main/go/net/eagle0/authservice:authservice_linux_amd64"],
|
||||
package_dir = "/app",
|
||||
)
|
||||
|
||||
oci_image(
|
||||
name = "auth_server_image",
|
||||
base = "@alpine_linux_linux_amd64",
|
||||
entrypoint = ["/app/authservice_linux_amd64"],
|
||||
exposed_ports = [
|
||||
"40033/tcp", # gRPC
|
||||
"8080/tcp", # HTTP OAuth callback
|
||||
],
|
||||
tars = [
|
||||
":busybox_layer",
|
||||
":auth_binary_layer",
|
||||
],
|
||||
workdir = "/app",
|
||||
)
|
||||
|
||||
# Load into Docker locally: bazel run //ci:auth_server_load
|
||||
oci_load(
|
||||
name = "auth_server_load",
|
||||
image = ":auth_server_image",
|
||||
repo_tags = ["eagle0/auth-server:latest"],
|
||||
)
|
||||
|
||||
# Push to DigitalOcean Container Registry
|
||||
oci_push(
|
||||
name = "auth_server_push",
|
||||
image = ":auth_server_image",
|
||||
repository = "registry.digitalocean.com/eagle0/auth-server",
|
||||
)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -0,0 +1,46 @@
|
||||
# OAuth Relay Worker
|
||||
|
||||
Cloudflare Worker that relays OAuth callbacks to the eagle0:// custom URL scheme.
|
||||
|
||||
## Why?
|
||||
|
||||
Discord (and some other OAuth providers) don't support custom URL schemes as redirect URIs. This worker acts as a relay:
|
||||
|
||||
1. Discord redirects to `https://eagle0-oauth-relay.<account>.workers.dev/oauth/callback?code=xxx&state=yyy`
|
||||
2. Worker responds with 302 redirect to `eagle0://auth/callback?code=xxx&state=yyy`
|
||||
3. OS opens the Eagle0 app via deep link
|
||||
|
||||
## Deploy
|
||||
|
||||
1. Install wrangler: `npm install -g wrangler`
|
||||
2. Login: `wrangler login`
|
||||
3. Deploy: `wrangler deploy`
|
||||
|
||||
The worker will be available at `https://eagle0-oauth-relay.<your-account>.workers.dev`
|
||||
|
||||
## Test Locally
|
||||
|
||||
```bash
|
||||
wrangler dev
|
||||
# Then in another terminal:
|
||||
curl -I "http://localhost:8787/oauth/callback?code=test&state=abc"
|
||||
```
|
||||
|
||||
## Test Production
|
||||
|
||||
```bash
|
||||
curl -I "https://eagle0-oauth-relay.<your-account>.workers.dev/oauth/callback?code=test&state=abc"
|
||||
```
|
||||
|
||||
Should return:
|
||||
```
|
||||
HTTP/2 302
|
||||
location: eagle0://auth/callback?code=test&state=abc
|
||||
```
|
||||
|
||||
## OAuth Provider Configuration
|
||||
|
||||
In Discord Developer Portal / Google Cloud Console, set the redirect URI to:
|
||||
```
|
||||
https://eagle0-oauth-relay.<your-account>.workers.dev/oauth/callback
|
||||
```
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* OAuth Relay Worker
|
||||
*
|
||||
* Receives OAuth callbacks from providers (Discord, Google) and redirects
|
||||
* to the eagle0:// custom URL scheme for the native app to handle.
|
||||
*
|
||||
* Input: GET /oauth/callback?code=xxx&state=yyy
|
||||
* Output: 302 Redirect to eagle0://auth/callback?code=xxx&state=yyy
|
||||
*/
|
||||
|
||||
export default {
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Only handle /oauth/callback path
|
||||
if (url.pathname !== '/oauth/callback') {
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
// Build the deep link URL
|
||||
const deepLink = new URL('eagle0://auth/callback');
|
||||
|
||||
// Forward all query parameters
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
const error = url.searchParams.get('error');
|
||||
const errorDescription = url.searchParams.get('error_description');
|
||||
|
||||
if (code) deepLink.searchParams.set('code', code);
|
||||
if (state) deepLink.searchParams.set('state', state);
|
||||
if (error) deepLink.searchParams.set('error', error);
|
||||
if (errorDescription) deepLink.searchParams.set('error_description', errorDescription);
|
||||
|
||||
console.log(`OAuth relay: redirecting to ${deepLink.toString()}`);
|
||||
|
||||
return Response.redirect(deepLink.toString(), 302);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
name = "eagle0-oauth-relay"
|
||||
main = "worker.js"
|
||||
compatibility_date = "2024-01-01"
|
||||
workers_dev = true
|
||||
+52
-1
@@ -1,7 +1,7 @@
|
||||
# Docker Compose for production deployment
|
||||
#
|
||||
# Local testing:
|
||||
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:shardok_server_load
|
||||
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:shardok_server_load && bazel run //ci:auth_server_load
|
||||
# Run: docker compose -f docker-compose.prod.yml up
|
||||
#
|
||||
# Production deployment:
|
||||
@@ -16,20 +16,30 @@ services:
|
||||
- "${GPT_MODEL_NAME:-gpt-5.1}"
|
||||
- "--shardok-interface-remote-address"
|
||||
- "shardok:40042"
|
||||
- "--auth-service-url"
|
||||
- "auth:40033"
|
||||
- "--internal-grpc-port"
|
||||
- "40034"
|
||||
ports:
|
||||
- "40032:40032"
|
||||
- "40034:40034" # Internal gRPC for auth service
|
||||
environment:
|
||||
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
|
||||
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
|
||||
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
|
||||
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
|
||||
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
|
||||
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
|
||||
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
|
||||
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
|
||||
volumes:
|
||||
- ./saves:/app/saves
|
||||
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-server jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
|
||||
- jvm-tmp:/tmp # Shared with jfr-sidecar for JVM attach socket files
|
||||
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
|
||||
depends_on:
|
||||
- shardok
|
||||
- auth
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
@@ -43,9 +53,47 @@ services:
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
auth:
|
||||
image: ${AUTH_IMAGE:-registry.digitalocean.com/eagle0/auth-server:latest}
|
||||
container_name: auth-server
|
||||
environment:
|
||||
# gRPC port for Auth service
|
||||
AUTH_GRPC_PORT: "40033"
|
||||
# HTTP port for OAuth callbacks
|
||||
AUTH_HTTP_PORT: "8080"
|
||||
# Eagle's internal gRPC address for InternalUserService
|
||||
EAGLE_INTERNAL_ADDRESS: "eagle:40034"
|
||||
# OAuth provider credentials
|
||||
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
|
||||
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
|
||||
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID:-}"
|
||||
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}"
|
||||
# Server base URL for OAuth callbacks
|
||||
SERVER_BASE_URL: "${SERVER_BASE_URL:-https://prod.eagle0.net}"
|
||||
# JWT keys - PEM files in volume, bootstrapped from JWK on first run
|
||||
JWT_KEYS_PATH: "/etc/eagle0/keys"
|
||||
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
|
||||
# Note: port 40033 is exposed via nginx, not directly
|
||||
volumes:
|
||||
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with Eagle
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "3"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "nc -z localhost 40033 || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
shardok:
|
||||
image: ${SHARDOK_IMAGE:-registry.digitalocean.com/eagle0/shardok-server:latest}
|
||||
container_name: shardok-server
|
||||
mem_limit: 1g
|
||||
memswap_limit: 1g # Prevent swap, OOM-kill cleanly instead
|
||||
ports:
|
||||
- "40042:40042"
|
||||
- "40052:40052"
|
||||
@@ -72,6 +120,7 @@ services:
|
||||
ports:
|
||||
- "443:443"
|
||||
- "80:80"
|
||||
- "40033:40033" # Go Auth service gRPC (Phase 2 direct client connections)
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./certbot/conf:/etc/letsencrypt:ro
|
||||
@@ -147,3 +196,5 @@ services:
|
||||
volumes:
|
||||
jvm-tmp:
|
||||
# Shared /tmp for JVM attach socket files between Eagle and jfr-sidecar
|
||||
jwt-keys:
|
||||
# Shared JWT RSA keys between Eagle and auth service
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
# Plan: Extract OAuth to Go Service
|
||||
|
||||
## Goal
|
||||
Move OAuth authentication handling from the Eagle Scala server into a separate Go service. This simplifies Eagle (gRPC-only, no HTTP), reduces complexity, and sets up for potentially moving JWT validation outside Eagle too.
|
||||
|
||||
## Architecture Decision: Sidecar Service (Not DO Functions)
|
||||
|
||||
**Recommendation: Go sidecar service on the same droplet, in a separate container**
|
||||
|
||||
**Why not DO Functions:**
|
||||
- OAuth requires **stateful sessions** (pendingOAuth/completedOAuth maps with 10-min TTL)
|
||||
- Client polling pattern (every 2 seconds) would incur high function invocation costs
|
||||
- Cold start latency problematic for auth flows
|
||||
- State would require external store (Redis), adding complexity
|
||||
|
||||
**Why sidecar (separate container):**
|
||||
- Simple process on same droplet, minimal network latency
|
||||
- In-memory state management (like current Scala impl)
|
||||
- Easy to monitor/debug alongside Eagle
|
||||
- Can share filesystem for key files (RSA keys) via volume mounts
|
||||
- **Independent deployment**: Deploying Eagle doesn't restart auth service (and vice versa)
|
||||
- **Independent scaling**: Could move to separate droplet later if needed
|
||||
|
||||
## Current Architecture (What Exists)
|
||||
|
||||
```
|
||||
Unity Client
|
||||
├── GetOAuthUrl RPC → Eagle AuthServiceImpl → OAuthService.getAuthUrl()
|
||||
├── [User browser auth] → HTTP callback → OAuthHttpHandler → OAuthService.handleCallback()
|
||||
├── CheckOAuthStatus RPC (polling) → AuthServiceImpl → OAuthService.checkStatus()
|
||||
└── All other RPCs include JWT → AuthorizationInterceptor validates
|
||||
```
|
||||
|
||||
**Key files:**
|
||||
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth flow, state management
|
||||
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - JWT creation/validation
|
||||
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD (persisted)
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC Auth service
|
||||
- `src/main/scala/net/eagle0/eagle/service/OAuthHttpHandler.scala` - HTTP callback handler
|
||||
|
||||
## Target Architecture (Phase 1)
|
||||
|
||||
```
|
||||
Unity Client
|
||||
├── GetOAuthUrl RPC ──────────────┐
|
||||
├── CheckOAuthStatus RPC (polling)├──→ Eagle (port 40032) ──proxy──→ Go Auth Container (port 40033)
|
||||
├── RefreshToken RPC ─────────────┘ │
|
||||
├── [User browser] → HTTP callback ────────────────────────────────────────┤
|
||||
│ ↓
|
||||
│ (Internal gRPC: GetOrCreateUser, GetUser)
|
||||
│ ↓
|
||||
└── Game RPCs with JWT ─────────────────────→ Eagle (port 40032) ← JWT validation stays here
|
||||
|
||||
[Same Droplet]
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ┌──────────────────────┐ ┌────────────────────────────────────┐ │
|
||||
│ │ Go Auth Container │◄────────►│ Eagle Container │ │
|
||||
│ │ (eagle0-auth) │ internal │ (eagle0-server) │ │
|
||||
│ │ │ gRPC │ │ │
|
||||
│ │ - OAuth flow │ │ - JWT validation │ │
|
||||
│ │ - JWT creation │ │ - UserService (persistence) │ │
|
||||
│ │ - HTTP callback │ │ - Game logic │ │
|
||||
│ └──────────────────────┘ └────────────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ └────────────────┬───────────────────────┘ │
|
||||
│ ▼ │
|
||||
│ /etc/eagle0/keys/ (shared volume) │
|
||||
│ - private.pem │
|
||||
│ - public.pem │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Component Responsibilities
|
||||
|
||||
### Go Auth Service (NEW - separate container)
|
||||
- **OAuth flow**: getAuthUrl, handleCallback (HTTP), checkStatus
|
||||
- **State management**: pendingOAuth, completedOAuth maps with TTL
|
||||
- **JWT creation**: Issue access/refresh tokens (shares RSA private key with Eagle)
|
||||
- **Token refresh**: Validate refresh token, issue new access token
|
||||
- Calls Eagle's internal UserService gRPC to find/create users
|
||||
|
||||
### Eagle Server (SIMPLIFIED)
|
||||
- **JWT validation**: AuthorizationInterceptor stays (validates tokens on game RPCs)
|
||||
- **UserService**: Stays in Eagle (user persistence, display name logic)
|
||||
- **New internal gRPC**: Expose GetOrCreateUser, GetUser for Go service to call
|
||||
- **Proxy (Phase 1)**: Forward OAuth RPCs to Go service
|
||||
- **Remove (Phase 2)**: OAuthService, OAuthHttpHandler, HTTP server setup
|
||||
|
||||
### Unity Client (NO CHANGES in Phase 1)
|
||||
- Eagle proxies Auth RPCs to Go service
|
||||
- Client still connects to Eagle on port 40032
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Go Auth Service with Eagle Proxy (Zero Client Changes)
|
||||
|
||||
1. **Create Go service structure**
|
||||
```
|
||||
src/main/go/net/eagle0/authservice/
|
||||
├── main.go # Entry point, starts gRPC + HTTP servers
|
||||
├── oauth.go # OAuth state management, provider configs
|
||||
├── jwt.go # JWT creation (copy logic from Scala)
|
||||
├── handlers.go # gRPC handlers for Auth service
|
||||
├── http_callback.go # HTTP handler for OAuth callback
|
||||
└── BUILD.bazel
|
||||
```
|
||||
|
||||
2. **Internal gRPC proto for Eagle UserService**
|
||||
```protobuf
|
||||
// src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto
|
||||
|
||||
service InternalUserService {
|
||||
rpc GetOrCreateUser(GetOrCreateUserRequest) returns (GetOrCreateUserResponse);
|
||||
rpc GetUser(GetUserRequest) returns (GetUserResponse);
|
||||
}
|
||||
|
||||
message GetOrCreateUserRequest {
|
||||
string provider = 1; // "discord" or "google"
|
||||
string provider_user_id = 2;
|
||||
string email = 3;
|
||||
string avatar_url = 4;
|
||||
}
|
||||
|
||||
message GetOrCreateUserResponse {
|
||||
string user_id = 1;
|
||||
string display_name = 2;
|
||||
string avatar_url = 3;
|
||||
bool is_admin = 4;
|
||||
bool is_new_user = 5;
|
||||
}
|
||||
```
|
||||
|
||||
3. **Eagle: Expose InternalUserService**
|
||||
- New `InternalUserServiceImpl.scala` wrapping UserService
|
||||
- Bind to same port, different service name (internal only)
|
||||
|
||||
4. **Eagle: Proxy Auth RPCs to Go**
|
||||
- AuthServiceImpl delegates GetOAuthUrl, CheckOAuthStatus, RefreshToken to Go service
|
||||
- SetDisplayName, GetCurrentUser, Logout stay in Eagle
|
||||
|
||||
5. **Share RSA keys via volume mount**
|
||||
- Go service reads same key files as Eagle
|
||||
- Both can create valid JWTs
|
||||
- Eagle continues to validate JWTs
|
||||
|
||||
6. **Docker/Container setup**
|
||||
- New Dockerfile for Go auth service
|
||||
- docker-compose or Kubernetes config for both containers
|
||||
- Shared volume for /etc/eagle0/keys/
|
||||
- Internal network for container-to-container gRPC
|
||||
|
||||
### Phase 2: Client Direct to Go Service (Future)
|
||||
|
||||
1. **Update Unity client**
|
||||
- Connect to Go Auth service directly for OAuth RPCs
|
||||
- Keep connecting to Eagle for game RPCs
|
||||
|
||||
2. **Remove Eagle proxy code**
|
||||
- Delete AuthServiceImpl OAuth delegation
|
||||
- AuthServiceImpl only handles SetDisplayName, GetCurrentUser, Logout
|
||||
|
||||
### Phase 3: Move JWT Validation to Go (Optional Future)
|
||||
|
||||
1. **Go service validates JWTs**
|
||||
- Add ValidateToken RPC or use shared middleware pattern
|
||||
|
||||
2. **Eagle calls Go for validation**
|
||||
- AuthorizationInterceptor calls Go to validate tokens
|
||||
- OR: Use stateless validation (both share public key)
|
||||
|
||||
## Files to Create
|
||||
|
||||
### Go Service
|
||||
- `src/main/go/net/eagle0/authservice/main.go`
|
||||
- `src/main/go/net/eagle0/authservice/oauth.go`
|
||||
- `src/main/go/net/eagle0/authservice/jwt.go`
|
||||
- `src/main/go/net/eagle0/authservice/handlers.go`
|
||||
- `src/main/go/net/eagle0/authservice/http_callback.go`
|
||||
- `src/main/go/net/eagle0/authservice/BUILD.bazel`
|
||||
|
||||
### Protos
|
||||
- `src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto`
|
||||
|
||||
### Scala
|
||||
- `src/main/scala/net/eagle0/eagle/service/InternalUserServiceImpl.scala`
|
||||
|
||||
### Docker/Deployment
|
||||
- `ci/auth_service.Dockerfile`
|
||||
- Update `docker-compose.yml` (or equivalent)
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### Scala (Phase 1)
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - Proxy OAuth RPCs to Go
|
||||
- `src/main/scala/net/eagle0/eagle/Main.scala` - Start internal user service, add auth-service-url flag
|
||||
|
||||
### Scala (Phase 2 - Removal)
|
||||
- Delete `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala`
|
||||
- Delete `src/main/scala/net/eagle0/eagle/service/OAuthHttpHandler.scala`
|
||||
- Simplify `src/main/scala/net/eagle0/eagle/Main.scala` - Remove HTTP server
|
||||
|
||||
### Unity (Phase 2)
|
||||
- `Assets/Auth/OAuthManager.cs` - Point OAuth RPCs to Go service port
|
||||
- `Assets/EagleConnection.cs` - Add second channel for auth service
|
||||
|
||||
## Key Implementation Details
|
||||
|
||||
### State Management in Go
|
||||
```go
|
||||
type OAuthState struct {
|
||||
Provider string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type OAuthResult struct {
|
||||
Success bool
|
||||
UserInfo *ProviderUserInfo
|
||||
Provider string
|
||||
Error string
|
||||
}
|
||||
|
||||
var pendingOAuth = sync.Map{} // state -> OAuthState
|
||||
var completedOAuth = sync.Map{} // state -> OAuthResult
|
||||
|
||||
const stateExpiration = 10 * time.Minute
|
||||
|
||||
// Background goroutine cleans expired states every minute
|
||||
func cleanupExpiredStates() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
for range ticker.C {
|
||||
cutoff := time.Now().Add(-stateExpiration)
|
||||
pendingOAuth.Range(func(key, value any) bool {
|
||||
if value.(OAuthState).CreatedAt.Before(cutoff) {
|
||||
pendingOAuth.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
// Similar for completedOAuth
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### JWT Creation in Go
|
||||
```go
|
||||
import "github.com/golang-jwt/jwt/v5"
|
||||
|
||||
type EagleClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
UserId string `json:"userId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
}
|
||||
|
||||
func CreateAccessToken(userId, displayName string, isAdmin bool) (string, error) {
|
||||
claims := EagleClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
UserId: userId,
|
||||
DisplayName: displayName,
|
||||
IsAdmin: isAdmin,
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
return token.SignedString(privateKey)
|
||||
}
|
||||
```
|
||||
|
||||
### OAuth Provider Configs
|
||||
- Read from environment variables (same as current OAuthConfig.scala)
|
||||
- DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET
|
||||
- GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
|
||||
- OAUTH_CALLBACK_URL (e.g., https://eagle0.shardok.games/oauth/callback)
|
||||
|
||||
### Container Networking
|
||||
```yaml
|
||||
# docker-compose.yml example
|
||||
services:
|
||||
eagle0-auth:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ci/auth_service.Dockerfile
|
||||
ports:
|
||||
- "40033:40033" # gRPC
|
||||
- "8080:8080" # HTTP callback
|
||||
volumes:
|
||||
- ./keys:/etc/eagle0/keys:ro
|
||||
environment:
|
||||
- DISCORD_CLIENT_ID
|
||||
- DISCORD_CLIENT_SECRET
|
||||
- GOOGLE_CLIENT_ID
|
||||
- GOOGLE_CLIENT_SECRET
|
||||
- EAGLE_INTERNAL_URL=eagle0-server:40034
|
||||
|
||||
eagle0-server:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ci/eagle_run.Dockerfile
|
||||
ports:
|
||||
- "40032:40032" # Public gRPC
|
||||
expose:
|
||||
- "40034" # Internal gRPC (container-to-container only)
|
||||
volumes:
|
||||
- ./keys:/etc/eagle0/keys:ro
|
||||
- ./data:/var/lib/eagle0
|
||||
environment:
|
||||
- AUTH_SERVICE_URL=eagle0-auth:40033
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Development
|
||||
```bash
|
||||
# Terminal 1: Go Auth Service
|
||||
bazel run //src/main/go/net/eagle0/authservice:authservice -- \
|
||||
--grpc-port=40033 \
|
||||
--http-port=8080 \
|
||||
--eagle-internal-url=localhost:40034
|
||||
|
||||
# Terminal 2: Eagle Server
|
||||
bazel run //src/main/scala/net/eagle0/eagle:eagle_server -- \
|
||||
--eagle-grpc-port=40032 \
|
||||
--internal-grpc-port=40034 \
|
||||
--auth-service-url=localhost:40033
|
||||
```
|
||||
|
||||
### Production
|
||||
- Both containers on same droplet via docker-compose
|
||||
- Shared volume for RSA keys at /etc/eagle0/keys/
|
||||
- Internal Docker network for container-to-container communication
|
||||
- External access: 40032 (Eagle gRPC), 8080 (OAuth HTTP callback)
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit tests for Go service**
|
||||
- OAuth state management (expiration, cleanup)
|
||||
- JWT creation matches Scala output (test with same keys)
|
||||
- HTTP callback parsing
|
||||
|
||||
2. **Integration tests**
|
||||
- Go service ↔ Eagle internal gRPC
|
||||
- Full OAuth flow with mock provider
|
||||
|
||||
3. **Existing tests continue to pass**
|
||||
- All Scala tests (JWT validation, user service)
|
||||
|
||||
4. **End-to-end test**
|
||||
- Spin up both containers
|
||||
- Run OAuth flow through proxy
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Key file permissions | Shared volume with read-only mount |
|
||||
| State loss on Go restart | Document this (same as current Scala behavior); consider Redis later |
|
||||
| Clock skew affecting JWT | Both on same machine |
|
||||
| OAuth callback race | HTTP callback completes before gRPC poll |
|
||||
| Container networking | Use docker-compose for reliable internal DNS |
|
||||
| Proxy adds latency | Minimal (same machine), remove in Phase 2 |
|
||||
|
||||
## Estimated Scope
|
||||
|
||||
- **Phase 1**: ~500-700 lines Go, ~100 lines Scala changes, ~50 lines Docker config
|
||||
- **Phase 2**: ~50 lines Unity, deletion of ~300 lines Scala
|
||||
- **Phase 3**: Optional, separate decision
|
||||
|
||||
## Alternative Considered: Move Everything to Go
|
||||
|
||||
Could move UserService to Go as well, but:
|
||||
- UserService is tightly integrated with game persistence
|
||||
- Would require duplicating persistence layer
|
||||
- Not worth the complexity for now
|
||||
|
||||
Keep UserService in Eagle, expose via internal gRPC.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **HTTP callback routing**: Does the OAuth callback URL need to change, or can we route traffic from the existing URL to the new Go service?
|
||||
2. **Health checks**: Should we add health check endpoints for container orchestration?
|
||||
3. **Logging**: Should Go service log to same format/destination as Eagle?
|
||||
@@ -0,0 +1,350 @@
|
||||
# OAuth Implementation: Next Steps and Design
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The OAuth implementation is functional but has several gaps that need addressing before it's production-ready. This document outlines the known issues, proposes a comprehensive user identity model, and provides a prioritized implementation plan.
|
||||
|
||||
## Current State (Updated January 2026)
|
||||
|
||||
### What Works ✅
|
||||
- Discord OAuth flow (server-mediated polling)
|
||||
- Google OAuth flow
|
||||
- JWT token generation and validation
|
||||
- User creation and display name setting
|
||||
- Auto-login with stored tokens
|
||||
- Basic game creation and play with OAuth users
|
||||
- Headshot fetching via public CDN (no auth required)
|
||||
- Logout button in lobby (preserves tokens for quick reconnect)
|
||||
- Environment (prod/qa) and user display in lobby
|
||||
- Game identity with userName = displayName (PR #4964 merged)
|
||||
|
||||
### Known Issues
|
||||
|
||||
#### 1. Game Identity Model Fragility (Deferred)
|
||||
**Status**: Accepted for now. PR #4964 merged with `userName = displayName`.
|
||||
|
||||
**Current behavior**:
|
||||
- Games store `userNameToFactionId: Map[String, Int]`
|
||||
- For JWT users, this maps displayName → factionId
|
||||
- displayName is technically mutable (users could change it)
|
||||
- No migration path when displayName changes
|
||||
|
||||
**Why this is acceptable**:
|
||||
1. We don't currently have a "change display name" feature
|
||||
2. The alternative (using userId) requires more extensive changes
|
||||
3. Can migrate to userId-based identity later if needed
|
||||
|
||||
#### 2. In-Game Headshot Fetching ✅ FIXED
|
||||
**Solution**: Made the `eagle0-headshots` S3 bucket public and enabled CDN.
|
||||
- Client now fetches directly from `https://eagle0-headshots.sfo3.cdn.digitaloceanspaces.com/`
|
||||
- No authentication required
|
||||
- Works for both OAuth and Basic Auth users
|
||||
- Simpler architecture, no dependency on home Mac server
|
||||
|
||||
#### 3. Logout from Lobby ✅ FIXED
|
||||
**Solution**: Added logout button to lobby UI (PR #4967).
|
||||
- Button disconnects from server and returns to connection screen
|
||||
- Intentionally does NOT clear OAuth tokens
|
||||
- Allows quick reconnect with same account without full OAuth flow
|
||||
|
||||
#### 4. Display Name Uniqueness Not Enforced (Medium) - OPEN
|
||||
**Problem**: User was able to set displayName "nolen" when that name was already taken.
|
||||
|
||||
**Root cause**: Unknown - needs investigation. Either:
|
||||
- The uniqueness check is buggy
|
||||
- The displayNameIndex wasn't populated correctly during user creation
|
||||
- Race condition during concurrent registrations
|
||||
|
||||
#### 5. Admin Server Crashes ✅ FIXED
|
||||
**Solution**: PR #4964 sets `userName = displayName` for JWT users.
|
||||
|
||||
#### 6. Intermittent "Expired" Errors During Login (Medium) - INVESTIGATING
|
||||
**Problem**: Users occasionally get "OAuth session expired" errors even when server logs show the callback succeeded.
|
||||
|
||||
**Status**: Added diagnostic logging in PR #4974 to trace:
|
||||
- State creation in `getAuthUrl`
|
||||
- State lookup in `handleCallback`
|
||||
- Result lookup in `checkStatus`
|
||||
|
||||
**Possible causes**:
|
||||
- State mismatch between client and server
|
||||
- Race condition in polling
|
||||
- Cleanup running at wrong time
|
||||
|
||||
#### 7. Token Expiry Field Bug ✅ FIXED
|
||||
**Problem**: `CheckOAuthStatusResponse.expiresAt` was returning refresh token expiry (30 days) instead of access token expiry (7 days).
|
||||
|
||||
**Solution**: Fixed in PR #4974 to calculate correct access token expiry.
|
||||
|
||||
---
|
||||
|
||||
## Proposed User Identity Model
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. **Stable Internal Identity**: `userId` (UUID) is the only key used for persistent associations
|
||||
2. **Display Name is Cosmetic**: Can change without breaking game associations
|
||||
3. **Backwards Compatibility**: Basic Auth continues to work for local development
|
||||
4. **Multi-Provider Support**: Users can link Discord, Google, and future providers
|
||||
5. **Avatar Flexibility**: Use OAuth avatar by default, support custom uploads later
|
||||
|
||||
### Data Model
|
||||
|
||||
```
|
||||
User {
|
||||
userId: String (UUID) // Primary key, immutable, used for all internal references
|
||||
displayName: String // Unique, user-visible, mutable with migration
|
||||
displayNameLower: String // Case-insensitive uniqueness
|
||||
email: String // Primary email for account recovery/linking
|
||||
avatarUrl: String // Current avatar URL
|
||||
avatarData: bytes // Cached avatar for offline/fast access (future)
|
||||
oauthIdentities: [OAuthIdentity]
|
||||
createdAt: Timestamp
|
||||
lastLoginAt: Timestamp
|
||||
isAdmin: Boolean
|
||||
}
|
||||
|
||||
OAuthIdentity {
|
||||
provider: String // "discord", "google", etc.
|
||||
providerUserId: String // Provider's user ID
|
||||
providerEmail: String // Email from this provider
|
||||
avatarUrl: String // Avatar from this provider
|
||||
linkedAt: Timestamp
|
||||
}
|
||||
```
|
||||
|
||||
### Identity Resolution Strategy
|
||||
|
||||
The key question: **What should `AuthorizationUtils.userName` return?**
|
||||
|
||||
#### Option A: userName = displayName (Current PR #4964)
|
||||
- **Pro**: Human-readable in logs, game saves, debugging
|
||||
- **Con**: Breaks if displayName changes
|
||||
- **Migration**: None needed now, complex later
|
||||
|
||||
#### Option B: userName = userId (Recommended)
|
||||
- **Pro**: Stable identity, displayName changes are safe
|
||||
- **Con**: UUIDs in logs are ugly, need display name lookup for UI
|
||||
- **Migration**: Cleaner long-term, but breaking change for any existing OAuth games
|
||||
|
||||
#### Option C: Hybrid with Migration Support
|
||||
- **userName** = userId for new games
|
||||
- **Legacy lookup** for old games by displayName
|
||||
- **Display layer** resolves userId → displayName for UI
|
||||
|
||||
**Recommendation**: Option B with a display name resolution layer. The ugliness in logs is acceptable for the stability it provides. Implement a `UserService.resolveDisplayName(identifier: String): String` that returns displayName for UUIDs or the identifier itself for legacy usernames.
|
||||
|
||||
### Account Linking Strategy
|
||||
|
||||
#### Automatic Linking (Future)
|
||||
When a user logs in with a new OAuth provider:
|
||||
1. Check if the provider email matches an existing user's email
|
||||
2. If match found, prompt: "An account exists with this email. Link accounts?"
|
||||
3. If confirmed, add new OAuthIdentity to existing user
|
||||
4. If declined, create separate account (different email required)
|
||||
|
||||
#### Manual Linking (MVP)
|
||||
1. User logs in with primary account
|
||||
2. User goes to Settings → Linked Accounts
|
||||
3. User clicks "Link Discord" or "Link Google"
|
||||
4. OAuth flow adds new identity to current user
|
||||
|
||||
### Avatar/Headshot Strategy
|
||||
|
||||
#### Phase 1: OAuth Avatars (MVP)
|
||||
- Store `avatarUrl` from OAuth provider during login
|
||||
- Server proxies avatar requests to avoid CORS issues
|
||||
- Cache avatars locally with TTL
|
||||
|
||||
#### Phase 2: Avatar Caching
|
||||
- Download avatar to local storage on login
|
||||
- Serve from local storage for reliability
|
||||
- Refresh periodically or on login
|
||||
|
||||
#### Phase 3: Custom Avatars (Future)
|
||||
- Allow users to upload custom avatar
|
||||
- Store in S3/DO Spaces
|
||||
- Custom avatar overrides OAuth avatar
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Stabilization ✅ COMPLETE
|
||||
|
||||
#### 1.1 Fix Display Name Uniqueness Bug - OPEN
|
||||
- [ ] Investigate why "nolen" was allowed when it existed
|
||||
- [ ] Add logging to `setDisplayName` to trace the issue
|
||||
- [ ] Ensure `displayNameIndex` is correctly maintained
|
||||
- [ ] Add unit tests for uniqueness enforcement
|
||||
|
||||
#### 1.2 Add Logout Button to Lobby ✅ DONE
|
||||
- [x] Add "Logout" button to lobby UI
|
||||
- [x] Disconnect from server
|
||||
- [x] Navigate to connection screen
|
||||
- [x] Preserve OAuth tokens for quick reconnect (intentional change from original plan)
|
||||
|
||||
#### 1.3 Merge PR #4964 (userName = displayName) ✅ DONE
|
||||
- [x] Merged - games work with OAuth users
|
||||
- [x] Documented limitation (games break if displayName changes)
|
||||
|
||||
#### 1.4 Fix Headshot Fetching ✅ DONE
|
||||
- [x] Made eagle0-headshots bucket public
|
||||
- [x] Client fetches directly from CDN
|
||||
- [x] No authentication required
|
||||
|
||||
#### 1.5 Add Lobby Status Display ✅ DONE
|
||||
- [x] Show environment (prod/qa) in lobby
|
||||
- [x] Show current user in lobby (OAuth displayName or classic username)
|
||||
|
||||
### Phase 2: Remaining Work (Priority Order)
|
||||
|
||||
#### 2.1 Diagnose Intermittent "Expired" Errors - IN PROGRESS
|
||||
- [x] Add diagnostic logging (PR #4974)
|
||||
- [ ] Deploy and reproduce the issue
|
||||
- [ ] Analyze logs to identify root cause
|
||||
- [ ] Implement fix based on findings
|
||||
|
||||
#### 2.2 Fix Display Name Uniqueness
|
||||
- [ ] Investigate UserService.setDisplayName logic
|
||||
- [ ] Check displayNameIndex population
|
||||
- [ ] Add logging to trace the issue
|
||||
- [ ] Fix the bug and add tests
|
||||
|
||||
#### 2.3 Wire Up Lobby UI in Unity
|
||||
- [ ] Connect `lobbyEnvironmentText` to TextMeshProUGUI in scene
|
||||
- [ ] Connect `lobbyUserText` to TextMeshProUGUI in scene
|
||||
|
||||
#### 2.4 Implement Token Refresh During Gameplay
|
||||
- [ ] Implement `RefreshToken` RPC on server (currently throws UNIMPLEMENTED)
|
||||
- [ ] Store refresh tokens server-side for validation
|
||||
- [ ] Add proactive refresh in client before token expires
|
||||
- [ ] Handle refresh during reconnection attempts
|
||||
|
||||
### Phase 3: Nice-to-Haves (Future)
|
||||
|
||||
#### 3.1 Proactive Token Refresh
|
||||
- [ ] Monitor token expiry in client
|
||||
- [ ] Refresh automatically when < 5 minutes remaining
|
||||
- [ ] Update TokenStorage with new access token
|
||||
|
||||
#### 3.2 Better Error Messages
|
||||
- [ ] Distinguish between network errors and auth errors
|
||||
- [ ] Show user-friendly messages for OAuth failures
|
||||
- [ ] Add retry suggestions
|
||||
|
||||
#### 3.3 Session Persistence Across Server Restarts
|
||||
- [ ] Move pendingOAuth from in-memory TrieMap to Redis/database
|
||||
- [ ] Move completedOAuth to Redis with TTL
|
||||
- [ ] Server can restart without breaking in-flight OAuth flows
|
||||
|
||||
#### 3.4 Migrate to userId-based Game Identity (Deferred)
|
||||
- [ ] Change `AuthorizationUtils.userName` to return `userId` for JWT users
|
||||
- [ ] Add `UserService.resolveDisplayName(id: String): String` for UI display
|
||||
- [ ] Update game UI to resolve userIds to displayNames
|
||||
- [ ] Existing Basic Auth games continue to work (userName is literal)
|
||||
|
||||
#### 3.5 Display Name Change Support (Requires 3.4)
|
||||
- [ ] Add `ChangeDisplayName` RPC
|
||||
- [ ] Validate new name is unique
|
||||
- [ ] Update user record
|
||||
- [ ] No game migration needed (games use userId)
|
||||
|
||||
### Phase 3: Multi-Provider Support (Future)
|
||||
|
||||
#### 3.1 Account Linking UI
|
||||
- [ ] Add Settings page with "Linked Accounts" section
|
||||
- [ ] Show currently linked providers
|
||||
- [ ] "Link Another Account" button triggers OAuth flow
|
||||
- [ ] `LinkOAuthProvider` RPC adds identity to current user
|
||||
|
||||
#### 3.2 Login Provider Selection
|
||||
- [ ] If user has multiple providers, any can be used to login
|
||||
- [ ] All resolve to same userId
|
||||
- [ ] Session shows which provider was used
|
||||
|
||||
#### 3.3 Account Merging (Complex)
|
||||
- [ ] Handle case where user created separate accounts
|
||||
- [ ] Merge game history, stats, etc.
|
||||
- [ ] Delete duplicate user record
|
||||
- [ ] This is complex - may defer or not implement
|
||||
|
||||
### Phase 4: Enhanced Avatars (Future)
|
||||
|
||||
#### 4.1 Avatar Caching
|
||||
- [ ] Download avatars to S3/DO Spaces on login
|
||||
- [ ] Serve from our CDN
|
||||
- [ ] Refresh on login if changed
|
||||
|
||||
#### 4.2 Custom Avatar Upload
|
||||
- [ ] Upload endpoint with size/format validation
|
||||
- [ ] Store in S3/DO Spaces
|
||||
- [ ] Custom avatar overrides OAuth avatar
|
||||
|
||||
---
|
||||
|
||||
## Technical Debt to Address
|
||||
|
||||
1. **Context Propagation in Futures**: PR #4960 fixed `setDisplayName` and `getCurrentUser`, but audit all `Future` blocks that access `AuthorizationUtils`
|
||||
|
||||
2. **Dual Auth Support**: The system supports both Basic Auth and JWT. Consider:
|
||||
- Should Basic Auth be deprecated for production?
|
||||
- Should it remain for local development only?
|
||||
- How do Basic Auth users interact with OAuth users in the same game?
|
||||
|
||||
3. **Token Refresh**: `RefreshToken` RPC throws UNIMPLEMENTED. Need to:
|
||||
- Implement refresh token storage and validation
|
||||
- Handle token refresh in client
|
||||
- Consider refresh token rotation for security
|
||||
|
||||
4. **Session Management**: No server-side session tracking. Consider:
|
||||
- Track active sessions per user
|
||||
- Allow "logout all devices"
|
||||
- Detect concurrent logins
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **What happens when a Basic Auth user and OAuth user have the same name?**
|
||||
- Currently possible - Basic Auth doesn't check UserService
|
||||
- Could cause confusion in games
|
||||
- Solution: Require OAuth for multiplayer? Or namespace Basic Auth names?
|
||||
|
||||
2. **Should displayName changes be allowed?**
|
||||
- With userId-based identity, it's safe
|
||||
- But could cause confusion ("who is this new player?")
|
||||
- Consider: rate limit changes, show "formerly known as" temporarily
|
||||
|
||||
3. **How to handle OAuth provider account deletion?**
|
||||
- User deletes their Discord account
|
||||
- Their Eagle0 account still exists
|
||||
- They can't login unless they linked another provider
|
||||
- Solution: Encourage linking multiple providers, or add email/password fallback
|
||||
|
||||
4. **Admin impersonation with OAuth**
|
||||
- Currently works via X-Impersonate-User header
|
||||
- Should this use userId or displayName?
|
||||
- Probably userId for stability
|
||||
|
||||
---
|
||||
|
||||
## Appendix: File Locations
|
||||
|
||||
### Server (Scala)
|
||||
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD
|
||||
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - Token generation/validation
|
||||
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth flow
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC Auth service
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthorizationInterceptor.scala` - Auth middleware
|
||||
- `src/main/scala/net/eagle0/eagle/service/AuthorizationUtils.scala` - Context accessors
|
||||
|
||||
### Client (C#)
|
||||
- `Assets/Auth/AuthClient.cs` - gRPC client for Auth service
|
||||
- `Assets/Auth/OAuthManager.cs` - OAuth flow orchestration
|
||||
- `Assets/Auth/TokenStorage.cs` - Persistent token storage
|
||||
- `Assets/Auth/JwtAuthInterceptor.cs` - Attaches JWT to requests
|
||||
|
||||
### Protos
|
||||
- `src/main/protobuf/net/eagle0/eagle/api/auth.proto` - Auth service definition
|
||||
- `src/main/protobuf/net/eagle0/eagle/internal/user/user.proto` - User data model
|
||||
@@ -0,0 +1,736 @@
|
||||
# Shardok On-Demand Compute Infrastructure Analysis
|
||||
|
||||
## Overview
|
||||
|
||||
This document analyzes options for running Shardok (the tactical combat AI server) on powerful on-demand compute, separate from the low-power droplet that hosts Eagle.
|
||||
|
||||
**Current State**: Shardok runs on the same DigitalOcean droplet as Eagle, limiting AI performance.
|
||||
|
||||
**Goal**: Spin up powerful compute only when battles occur, keeping costs low while improving AI quality.
|
||||
|
||||
---
|
||||
|
||||
## Cloud Provider Pricing Comparison
|
||||
|
||||
### High-Performance CPU Options (8-16 cores)
|
||||
|
||||
| Provider | Instance Type | vCPUs | RAM | Hourly Cost | Monthly Est.* |
|
||||
|----------|--------------|-------|-----|-------------|---------------|
|
||||
| **Hetzner** | CCX23 | 8 | 32GB | €0.078 (~$0.085) | ~$6-8 |
|
||||
| **Hetzner** | CCX33 | 16 | 64GB | €0.155 (~$0.17) | ~$12-16 |
|
||||
| **Vultr** | High Frequency 8-core | 8 | 32GB | $0.238 | ~$20-25 |
|
||||
| **Vultr** | High Frequency 16-core | 16 | 64GB | $0.476 | ~$40-50 |
|
||||
| **DigitalOcean** | Premium CPU 8-core | 8 | 32GB | $0.250 | ~$22-28 |
|
||||
| **DigitalOcean** | Premium CPU 16-core | 16 | 64GB | $0.500 | ~$44-55 |
|
||||
|
||||
*Monthly estimates assume ~4 hours/week of battle usage (~17 hours/month)
|
||||
|
||||
### Key Finding: Hetzner is 3-5x Cheaper
|
||||
|
||||
Hetzner's dedicated vCPU instances offer the best price-performance ratio by a significant margin. For equivalent 16-core compute:
|
||||
- Hetzner: ~$0.17/hr
|
||||
- DigitalOcean: ~$0.50/hr (2.9x more expensive)
|
||||
- Vultr: ~$0.48/hr (2.8x more expensive)
|
||||
|
||||
---
|
||||
|
||||
## Instance Startup Time Analysis
|
||||
|
||||
### Current Startup Times by Provider
|
||||
|
||||
| Provider | Cold Start | Warm Image | Container Registry |
|
||||
|----------|-----------|------------|-------------------|
|
||||
| Hetzner | 30-60s | 20-40s | Native support |
|
||||
| Vultr | 30-45s | 20-30s | Native support |
|
||||
| DigitalOcean | 30-60s | 25-45s | Already in use |
|
||||
|
||||
### 10-Second Max Wait Time Constraint
|
||||
|
||||
A 10-second startup constraint eliminates traditional VM spin-up as an option. Alternatives:
|
||||
|
||||
#### Option 1: Pre-warmed Instance Pool (Recommended)
|
||||
- Keep one small instance running 24/7 as a "hot standby"
|
||||
- Cost: ~$5-10/month for a minimal Hetzner instance
|
||||
- Startup time: **< 1 second** (already running)
|
||||
- Upgrade on demand when battle starts (resize takes 2-3 min, but battle can start immediately on small instance)
|
||||
|
||||
#### Option 2: Predictive Spin-up
|
||||
- Start spinning up when user enters battle setup
|
||||
- Battle setup typically takes 30-60 seconds (map selection, army placement)
|
||||
- Could achieve "instant" availability if spin-up completes during setup
|
||||
- Risk: User might cancel, wasting spin-up cost (minimal at ~$0.01-0.02)
|
||||
|
||||
#### Option 3: Container-Based Quick Start
|
||||
- Use pre-pulled Docker images on a warm container host
|
||||
- Providers like Fly.io or Railway can start containers in 1-5 seconds
|
||||
- Trade-off: Container platforms typically cost more than raw VMs
|
||||
|
||||
#### Option 4: Serverless/Function Compute
|
||||
- Not viable for Shardok due to:
|
||||
- Long-running connections (battles last minutes)
|
||||
- High memory requirements
|
||||
- Stateful game state
|
||||
|
||||
### Recommendation for 10s Constraint
|
||||
|
||||
**Hybrid approach**: Keep a minimal "always-on" instance ($5-10/month) that can handle battles at reduced AI quality. When a battle is anticipated (entering battle setup), start spinning up a powerful instance. If the powerful instance is ready when the battle starts, use it; otherwise, fall back to the always-on instance.
|
||||
|
||||
---
|
||||
|
||||
## ARM Options Outside AWS
|
||||
|
||||
### Available ARM Compute
|
||||
|
||||
| Provider | ARM Offering | vCPUs | RAM | Hourly Cost | Notes |
|
||||
|----------|-------------|-------|-----|-------------|-------|
|
||||
| **Hetzner** | CAX21 | 4 | 8GB | €0.009 | Ampere Altra |
|
||||
| **Hetzner** | CAX31 | 8 | 16GB | €0.018 | Ampere Altra |
|
||||
| **Hetzner** | CAX41 | 16 | 32GB | €0.035 | Ampere Altra |
|
||||
| **Oracle Cloud** | A1 Flex | 4 | 24GB | Free tier | Always Free eligible |
|
||||
| **Oracle Cloud** | A1 Flex | 24 | 144GB | ~$0.10 | Ampere Altra |
|
||||
| **Scaleway** | AMP2-C8 | 8 | 16GB | €0.104 | Ampere Altra |
|
||||
|
||||
### Key Finding: Hetzner ARM is Extremely Cheap
|
||||
|
||||
Hetzner's ARM instances are 4-5x cheaper than their x86 equivalents:
|
||||
- CAX41 (16 ARM cores): €0.035/hr (~$0.04)
|
||||
- CCX33 (16 x86 cores): €0.155/hr (~$0.17)
|
||||
|
||||
### ARM Considerations for Shardok
|
||||
|
||||
**Pros:**
|
||||
- Significant cost savings (4-5x)
|
||||
- Good single-threaded performance on Ampere Altra
|
||||
- Native ARM builds already supported (macOS development)
|
||||
|
||||
**Cons:**
|
||||
- Requires ARM64 Linux build (currently only building x86_64 Linux)
|
||||
- Some performance tuning may be needed
|
||||
- Less mature ecosystem than x86
|
||||
|
||||
**Build Effort**: Medium. Bazel supports cross-compilation, and the codebase is portable C++. Main work is adding ARM64 Linux to the build matrix.
|
||||
|
||||
---
|
||||
|
||||
## Hetzner Geographic Presence
|
||||
|
||||
### Current Locations
|
||||
|
||||
Hetzner operates datacenters in:
|
||||
- **Germany**: Nuremberg (NBG1), Falkenstein (FSN1)
|
||||
- **Finland**: Helsinki (HEL1)
|
||||
- **USA**: Ashburn, Virginia (ASH) - **opened 2023**
|
||||
- **Singapore**: (coming 2024/2025)
|
||||
|
||||
### Latency Implications
|
||||
|
||||
For users primarily in North America:
|
||||
- **Hetzner US (Ashburn)**: 20-40ms to East Coast, 60-80ms to West Coast
|
||||
- **Hetzner EU**: 80-120ms from US East Coast, 140-180ms from US West Coast
|
||||
|
||||
Since Shardok handles AI computation (not real-time player input), latency is less critical. The Eagle-Shardok communication adds some overhead, but AI "thinking time" dominates.
|
||||
|
||||
### Recommendation
|
||||
|
||||
Use **Hetzner US (Ashburn)** for North American users. The US datacenter has the same pricing as EU datacenters and provides better latency.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Options Summary
|
||||
|
||||
### Option A: Simple On-Demand (30-60s startup)
|
||||
- Spin up Hetzner CCX33 when battle starts
|
||||
- Cost: ~$0.17/hr × 4 hrs/week = **~$3/month**
|
||||
- Latency: 30-60s wait at battle start
|
||||
- Complexity: Low
|
||||
|
||||
### Option B: Predictive Spin-up (0-30s startup)
|
||||
- Start spinning up during battle setup phase
|
||||
- Cost: Same as Option A, plus occasional wasted spin-ups (~$1/month)
|
||||
- Latency: 0-30s depending on setup duration
|
||||
- Complexity: Medium
|
||||
|
||||
### Option C: Hybrid Always-On + On-Demand (< 1s startup)
|
||||
- Keep minimal instance always running ($5-10/month)
|
||||
- Spin up powerful instance during battle setup
|
||||
- Use powerful if ready, fall back to minimal
|
||||
- Cost: **~$8-15/month**
|
||||
- Latency: < 1s (always-on fallback)
|
||||
- Complexity: Medium-High
|
||||
|
||||
### Option D: ARM Compute (Cheapest)
|
||||
- Build ARM64 Linux target
|
||||
- Use Hetzner CAX41 (16 ARM cores) at €0.035/hr
|
||||
- Cost: **~$0.60/month** for compute (+ always-on if needed)
|
||||
- Latency: Same as x86 options
|
||||
- Complexity: Medium (build system changes)
|
||||
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Phase 1 (Quick Win)**: Implement Option B (Predictive Spin-up) with Hetzner CCX33 in Ashburn.
|
||||
- Minimal code changes (add spin-up trigger in Eagle when battle setup begins)
|
||||
- Good user experience (usually ready by battle start)
|
||||
- Low cost (~$3-5/month)
|
||||
|
||||
**Phase 2 (Future)**: Add ARM64 Linux build and switch to ARM instances.
|
||||
- 4-5x cost reduction
|
||||
- Same or better performance
|
||||
- Requires build system work
|
||||
|
||||
**Phase 3 (If Needed)**: Add always-on fallback for guaranteed instant start.
|
||||
- Only if Phase 1 startup times prove problematic
|
||||
- Adds ~$5-10/month fixed cost
|
||||
|
||||
---
|
||||
|
||||
# Implementation Plan
|
||||
|
||||
## Chosen Architecture
|
||||
|
||||
- **Provider**: Hetzner Cloud
|
||||
- **Instance**: CAX41 (16 ARM cores, 32GB RAM)
|
||||
- **Location**: Ashburn, Virginia (ash)
|
||||
- **Cost**: €0.035/hr (~$0.04/hr)
|
||||
- **Strategy**: On-demand with predictive spin-up and idle keep-alive
|
||||
|
||||
---
|
||||
|
||||
## System Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ DigitalOcean │
|
||||
│ ┌─────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Eagle Server │ │
|
||||
│ │ ┌─────────────────┐ ┌─────────────────┐ │ │
|
||||
│ │ │ ShardokManager │ │ ActivityTracker │ │ │
|
||||
│ │ │ - spin up/down │ │ - player active │ │ │
|
||||
│ │ │ - health check │ │ - trigger start │ │ │
|
||||
│ │ └────────┬────────┘ └────────┬────────┘ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ └────────┬───────────┘ │ │
|
||||
│ │ ▼ │ │
|
||||
│ │ ┌────────────────┐ │ │
|
||||
│ │ │ ShardokClient │◄─── TLS + Token Auth │ │
|
||||
│ │ └────────┬───────┘ │ │
|
||||
│ └────────────────────┼─────────────────────────────────────────┘ │
|
||||
└───────────────────────┼──────────────────────────────────────────────┘
|
||||
│ gRPC over TLS (Let's Encrypt)
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────────────┐
|
||||
│ Hetzner Cloud (Ashburn) │
|
||||
│ ┌─────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ CAX41 (On-Demand) │ │
|
||||
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ Shardok Server │ │ │
|
||||
│ │ │ - 16 ARM cores for AI computation │ │ │
|
||||
│ │ │ - TLS cert from Let's Encrypt (auto-renewed) │ │ │
|
||||
│ │ │ - Validates auth token on each request │ │ │
|
||||
│ │ └─────────────────────────────────────────────────────────┘ │ │
|
||||
│ └─────────────────────────────────────────────────────────────────┘ │
|
||||
└───────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Instance Lifecycle Management
|
||||
|
||||
### State Machine
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
│ STOPPED │◄────────────────────┐
|
||||
└──────┬───────┘ │
|
||||
│ battle imminent │ idle timeout
|
||||
▼ │ (5 minutes)
|
||||
┌──────────────┐ │
|
||||
│ STARTING │ │
|
||||
└──────┬───────┘ │
|
||||
│ health check passes │
|
||||
▼ │
|
||||
┌──────────────┐ │
|
||||
┌─────────►│ READY │─────────────────────┤
|
||||
│ └──────┬───────┘ │
|
||||
│ │ battle starts │
|
||||
│ ▼ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ IN_USE │ │
|
||||
│ └──────┬───────┘ │
|
||||
│ │ battle ends │
|
||||
│ ▼ │
|
||||
│ ┌──────────────┐ │
|
||||
└──────────┤ IDLE ├─────────────────────┘
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
### Lifecycle Rules
|
||||
|
||||
1. **Spin-up Trigger** ("battle imminent"):
|
||||
- Player enters battle setup screen
|
||||
- Army placement phase begins
|
||||
- Strategic action that will trigger a battle (e.g., march into enemy territory)
|
||||
|
||||
2. **Keep-Alive Period**: 5 minutes after last battle ends
|
||||
- Allows quick start if another battle occurs
|
||||
- Cost: ~$0.003 per keep-alive period (negligible)
|
||||
|
||||
3. **Shutdown**: After 5 minutes idle with no pending battles
|
||||
- Hetzner API: `DELETE /servers/{id}` or power off to stop billing
|
||||
|
||||
4. **Health Checks**: Every 10 seconds while STARTING or READY
|
||||
- gRPC health check endpoint
|
||||
- Mark READY only when Shardok responds successfully
|
||||
|
||||
---
|
||||
|
||||
## Spin-up Strategy
|
||||
|
||||
### Simple Approach: Activity-Based
|
||||
|
||||
Keep Shardok running whenever there's player activity. The cost is so low (~$0.04/hr) that optimizing spin-up triggers isn't worth the complexity.
|
||||
|
||||
**Spin-up triggers:**
|
||||
- Any player connects to Eagle
|
||||
- March command into hostile territory (battle likely)
|
||||
|
||||
**Shutdown triggers:**
|
||||
- No player has taken a turn in the last hour
|
||||
- No active battles
|
||||
|
||||
This means Shardok stays running during active play sessions and shuts down during idle periods.
|
||||
|
||||
### Cost Impact
|
||||
|
||||
| Scenario | Hours/Week | Cost/Month |
|
||||
|----------|------------|------------|
|
||||
| Optimized (battles only) | ~4 hrs | ~$0.70 |
|
||||
| Activity-based (during play) | ~20 hrs | ~$3.50 |
|
||||
| Always-on 24/7 | 168 hrs | ~$29 |
|
||||
|
||||
Activity-based is a good middle ground: simple logic, minimal user wait time, still very cheap.
|
||||
|
||||
### Implementation in Eagle
|
||||
|
||||
```scala
|
||||
class ShardokInstanceManager {
|
||||
private var instanceState: InstanceState = STOPPED
|
||||
private var lastActivityTime: Instant = Instant.MIN
|
||||
|
||||
// Called when any player connects or takes a turn
|
||||
def onPlayerActivity(): Unit = {
|
||||
lastActivityTime = Instant.now()
|
||||
if (instanceState == STOPPED) {
|
||||
startInstance()
|
||||
}
|
||||
}
|
||||
|
||||
// Called on march into hostile territory for earlier spin-up
|
||||
def onBattleLikely(): Unit = {
|
||||
onPlayerActivity() // Same effect, just a semantic alias
|
||||
}
|
||||
|
||||
def onBattleEnded(battleId: BattleId): Unit = {
|
||||
lastActivityTime = Instant.now()
|
||||
// Don't shut down - keep alive for potential next battle
|
||||
}
|
||||
|
||||
// Run every 10 minutes
|
||||
def periodicShutdownCheck(): Unit = {
|
||||
val idleTime = Duration.between(lastActivityTime, Instant.now())
|
||||
if (instanceState != STOPPED && idleTime > Duration.ofHours(1)) {
|
||||
stopInstance()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security: Eagle ↔ Shardok Authentication
|
||||
|
||||
Since Eagle (DigitalOcean) and Shardok (Hetzner) are on different cloud providers, they cannot share a private network. We need secure authentication over the public internet.
|
||||
|
||||
### Approach: TLS + Token Authentication
|
||||
|
||||
This is the same pattern used by Stripe, GitHub, and most cloud APIs.
|
||||
|
||||
**Layer 1: TLS Encryption (Let's Encrypt)**
|
||||
- Server certificate from Let's Encrypt, auto-renewed via certbot
|
||||
- All gRPC traffic encrypted with TLS 1.3
|
||||
- Prevents eavesdropping and MITM attacks
|
||||
|
||||
**Layer 2: Token Authentication**
|
||||
- 256-bit random shared secret in gRPC metadata
|
||||
- Only requests with valid token are processed
|
||||
- Easy to rotate without any certificate changes
|
||||
|
||||
### How Token Auth Works
|
||||
|
||||
**Setup (once):**
|
||||
```bash
|
||||
# Generate a 256-bit random token
|
||||
openssl rand -hex 32
|
||||
# Output: a3f8b2c9d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1
|
||||
|
||||
# Store in GitHub Secrets as SHARDOK_AUTH_TOKEN
|
||||
# Eagle reads from environment, Shardok receives via cloud-init
|
||||
```
|
||||
|
||||
**Every Request:**
|
||||
```
|
||||
Eagle Shardok
|
||||
────── ───────
|
||||
gRPC call with metadata: → Validate token:
|
||||
"authorization: Bearer a3f8b2c9..." if token != expected:
|
||||
return UNAUTHENTICATED
|
||||
else:
|
||||
process request
|
||||
```
|
||||
|
||||
### Server Certificate (Let's Encrypt)
|
||||
|
||||
Shardok needs a domain name for Let's Encrypt. Options:
|
||||
|
||||
1. **Dynamic DNS**: Assign `shardok.eagle0.net` to instance IP on spin-up
|
||||
2. **Elastic IP**: Reserve a Hetzner floating IP, always points to active instance
|
||||
|
||||
Cloud-init runs certbot on first boot:
|
||||
|
||||
```yaml
|
||||
runcmd:
|
||||
# Install certbot
|
||||
- apt-get install -y certbot
|
||||
|
||||
# Get certificate (DNS must already point to this IP)
|
||||
- certbot certonly --standalone -d shardok.eagle0.net --non-interactive --agree-tos -m admin@eagle0.net
|
||||
|
||||
# Certificate auto-renews via systemd timer (certbot installs this)
|
||||
# Certs at: /etc/letsencrypt/live/shardok.eagle0.net/
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
**Shardok gRPC Server (C++):**
|
||||
```cpp
|
||||
// Load Let's Encrypt certificates
|
||||
std::string cert = ReadFile("/etc/letsencrypt/live/shardok.eagle0.net/fullchain.pem");
|
||||
std::string key = ReadFile("/etc/letsencrypt/live/shardok.eagle0.net/privkey.pem");
|
||||
|
||||
grpc::SslServerCredentialsOptions ssl_opts;
|
||||
ssl_opts.pem_key_cert_pairs.push_back({key, cert});
|
||||
auto creds = grpc::SslServerCredentials(ssl_opts);
|
||||
builder.AddListeningPort("0.0.0.0:50051", creds);
|
||||
|
||||
// Token validation - check on every RPC
|
||||
Status PostCommand(ServerContext* context, const Request* req, Response* resp) {
|
||||
auto auth = context->client_metadata().find("authorization");
|
||||
std::string expected = "Bearer " + auth_token_;
|
||||
if (auth == context->client_metadata().end() ||
|
||||
auth->second != expected) {
|
||||
return Status(grpc::UNAUTHENTICATED, "Invalid token");
|
||||
}
|
||||
// ... handle request
|
||||
}
|
||||
```
|
||||
|
||||
**Eagle gRPC Client (Scala):**
|
||||
```scala
|
||||
// Standard TLS (trusts Let's Encrypt via system CA store)
|
||||
val channel = NettyChannelBuilder
|
||||
.forAddress("shardok.eagle0.net", 50051)
|
||||
.useTransportSecurity() // TLS with system trust store
|
||||
.intercept(new ClientInterceptor {
|
||||
override def interceptCall[Req, Resp](
|
||||
method: MethodDescriptor[Req, Resp],
|
||||
callOptions: CallOptions,
|
||||
next: Channel): ClientCall[Req, Resp] = {
|
||||
new ForwardingClientCall.SimpleForwardingClientCall(
|
||||
next.newCall(method, callOptions)) {
|
||||
override def start(listener: Listener[Resp], headers: Metadata): Unit = {
|
||||
headers.put(
|
||||
Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER),
|
||||
s"Bearer $authToken"
|
||||
)
|
||||
super.start(listener, headers)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.build()
|
||||
```
|
||||
|
||||
### Token Rotation
|
||||
|
||||
When rotating tokens (e.g., if compromised or as routine hygiene):
|
||||
|
||||
1. Generate new token
|
||||
2. Update Shardok to accept **both** old and new tokens temporarily
|
||||
3. Update Eagle to use new token
|
||||
4. Remove old token from Shardok
|
||||
|
||||
No downtime, no certificate regeneration.
|
||||
|
||||
### Firewall Rules
|
||||
|
||||
```bash
|
||||
# Hetzner firewall via cloud-init
|
||||
ufw default deny incoming
|
||||
ufw allow 80/tcp # Let's Encrypt HTTP-01 challenge (certbot)
|
||||
ufw allow 50051/tcp # gRPC (protected by TLS + token)
|
||||
ufw allow from <admin-ip>/32 to any port 22 # SSH for debugging
|
||||
ufw enable
|
||||
```
|
||||
|
||||
### Security Summary
|
||||
|
||||
| Threat | Mitigation |
|
||||
|--------|------------|
|
||||
| Eavesdropping | TLS 1.3 encryption |
|
||||
| MITM attack | Let's Encrypt certificate validates server identity |
|
||||
| Unauthorized access | 256-bit token required on every request |
|
||||
| Token theft | TLS prevents sniffing; rotate if compromised |
|
||||
|
||||
This is the industry-standard approach for API security.
|
||||
|
||||
---
|
||||
|
||||
## Hetzner API Integration
|
||||
|
||||
### API Authentication
|
||||
|
||||
```bash
|
||||
# Store in secrets manager
|
||||
HETZNER_API_TOKEN=<token from Hetzner Cloud Console>
|
||||
```
|
||||
|
||||
### Key API Operations
|
||||
|
||||
**Create Instance:**
|
||||
```bash
|
||||
curl -X POST "https://api.hetzner.cloud/v1/servers" \
|
||||
-H "Authorization: Bearer $HETZNER_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "shardok-prod",
|
||||
"server_type": "cax41",
|
||||
"location": "ash",
|
||||
"image": "docker-ce",
|
||||
"ssh_keys": ["eagle-deploy"],
|
||||
"user_data": "<cloud-init script>",
|
||||
"labels": {"service": "shardok", "env": "prod"}
|
||||
}'
|
||||
```
|
||||
|
||||
**Check Status:**
|
||||
```bash
|
||||
curl "https://api.hetzner.cloud/v1/servers/{id}" \
|
||||
-H "Authorization: Bearer $HETZNER_API_TOKEN"
|
||||
# Response includes: status (running/starting/off), public IP
|
||||
```
|
||||
|
||||
**Delete Instance (Stop Billing):**
|
||||
```bash
|
||||
curl -X DELETE "https://api.hetzner.cloud/v1/servers/{id}" \
|
||||
-H "Authorization: Bearer $HETZNER_API_TOKEN"
|
||||
```
|
||||
|
||||
### Cloud-Init Script
|
||||
|
||||
```yaml
|
||||
#cloud-config
|
||||
package_update: true
|
||||
packages:
|
||||
- docker.io
|
||||
- certbot
|
||||
|
||||
write_files:
|
||||
- path: /etc/shardok/auth_token
|
||||
permissions: '0600'
|
||||
content: |
|
||||
${AUTH_TOKEN}
|
||||
|
||||
runcmd:
|
||||
# Get Let's Encrypt certificate (DNS must point to this IP first)
|
||||
- certbot certonly --standalone -d shardok.eagle0.net --non-interactive --agree-tos -m admin@eagle0.net
|
||||
|
||||
# Pull and run Shardok container
|
||||
- echo ${DO_REGISTRY_TOKEN} | docker login registry.digitalocean.com -u ${DO_REGISTRY_TOKEN} --password-stdin
|
||||
- docker pull registry.digitalocean.com/eagle0/shardok-server-arm64:latest
|
||||
- docker run -d --name shardok \
|
||||
-p 50051:50051 \
|
||||
-v /etc/letsencrypt:/etc/letsencrypt:ro \
|
||||
-v /etc/shardok:/etc/shardok:ro \
|
||||
registry.digitalocean.com/eagle0/shardok-server-arm64:latest \
|
||||
--cert=/etc/letsencrypt/live/shardok.eagle0.net/fullchain.pem \
|
||||
--key=/etc/letsencrypt/live/shardok.eagle0.net/privkey.pem \
|
||||
--token-file=/etc/shardok/auth_token
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Container Registry
|
||||
|
||||
Using DigitalOcean Container Registry for all images (consistent with Eagle and other services).
|
||||
|
||||
- ARM64 image: `registry.digitalocean.com/eagle0/shardok-server-arm64:latest`
|
||||
- x86 image: `registry.digitalocean.com/eagle0/shardok-server:latest`
|
||||
|
||||
CI automatically builds and pushes ARM64 images when C++ code changes.
|
||||
|
||||
---
|
||||
|
||||
## Fallback Strategy
|
||||
|
||||
### If Shardok Instance Not Ready
|
||||
|
||||
When a battle starts but the Hetzner instance isn't ready:
|
||||
|
||||
1. **Option A: Wait with Progress Indicator**
|
||||
- Show "Preparing battle arena..." with spinner
|
||||
- Most users won't notice 20-30s wait if UI is engaging
|
||||
- Simplest implementation
|
||||
|
||||
2. **Option B: Local Fallback** (Current behavior)
|
||||
- Run Shardok on Eagle's droplet temporarily
|
||||
- Lower AI quality but instant start
|
||||
- Migrate battle to Hetzner when ready (complex)
|
||||
|
||||
3. **Option C: Reduced AI Settings**
|
||||
- Start battle immediately with very short time budgets
|
||||
- Improve AI quality when Hetzner ready
|
||||
- Seamless to user but requires AI hot-swap
|
||||
|
||||
**Recommendation**: Start with Option A. If user feedback indicates wait time is problematic, implement Option B.
|
||||
|
||||
---
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
### Metrics to Track
|
||||
|
||||
| Metric | Purpose |
|
||||
|--------|---------|
|
||||
| `shardok_instance_state` | Current lifecycle state |
|
||||
| `shardok_startup_duration_seconds` | Time from spin-up to ready |
|
||||
| `shardok_battles_per_instance` | Efficiency of keep-alive |
|
||||
| `shardok_wasted_spinups` | Spin-ups without battles |
|
||||
| `shardok_hourly_cost` | Running cost tracking |
|
||||
|
||||
### Alerting
|
||||
|
||||
- Alert if startup takes > 90 seconds
|
||||
- Alert if instance stuck in STARTING for > 3 minutes
|
||||
- Alert if Hetzner API errors persist
|
||||
|
||||
---
|
||||
|
||||
## Cost Estimate (Activity-Based, ARM)
|
||||
|
||||
With activity-based spin-up, Shardok runs during active play sessions:
|
||||
|
||||
| Play Pattern | Hours/Week | Compute/Month |
|
||||
|--------------|------------|---------------|
|
||||
| Casual (few sessions) | ~10 hrs | **~$1.75** |
|
||||
| Regular (daily play) | ~20 hrs | **~$3.50** |
|
||||
| Heavy (multiple daily) | ~40 hrs | **~$7.00** |
|
||||
|
||||
Plus fixed costs:
|
||||
- Container registry: $0 (GitHub Container Registry)
|
||||
- Secrets management: $0 (GitHub Secrets)
|
||||
|
||||
**Total estimated cost: $2-7/month** for typical usage.
|
||||
|
||||
Compare to current DigitalOcean droplet upgrade (~$20-40/month for equivalent CPU power).
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Basic On-Demand (1-2 weeks)
|
||||
|
||||
1. ✅ Add ARM64 Linux to CI build matrix (PR #4990, merged 2026-01-02)
|
||||
- Added LLVM toolchain for ARM64 cross-compilation
|
||||
- Created ARM64 sysroot build workflow
|
||||
- Added Ubuntu 24.04 ARM64 base image for containers
|
||||
2. ✅ Push ARM64 container to DigitalOcean Container Registry (PR #4990)
|
||||
- Created shardok_arm64_build.yml workflow
|
||||
- Pushes to registry.digitalocean.com/eagle0/shardok-server-arm64:latest
|
||||
3. ✅ Implement Hetzner API client in Eagle (Scala) (PR #4996, merged 2026-01-02)
|
||||
- HetznerApiClient with create/delete/list/power operations
|
||||
- Async HTTP via OkHttp with Future-based API
|
||||
4. ✅ Add ShardokInstanceManager with spin-up/shutdown logic (PR #4998, merged 2026-01-02)
|
||||
- Activity-based spin-up when players connect
|
||||
- Idle timeout shutdown (default 60 minutes)
|
||||
- State machine: Stopped → Starting → Ready → InUse → Stopping
|
||||
5. ✅ Add TLS + token authentication to Shardok server (PR #5001, merged 2026-01-02)
|
||||
- C++ server: TLS via Let's Encrypt, Bearer token validation
|
||||
- Scala client: TLS and auth token support in ServerSetupHelpers
|
||||
6. Test end-to-end with manual triggers ← **CURRENT**
|
||||
|
||||
### Phase 2: Predictive Spin-up (1 week)
|
||||
|
||||
1. Identify battle-imminent triggers in Eagle code
|
||||
2. Wire triggers to ShardokInstanceManager
|
||||
3. Implement keep-alive timer
|
||||
4. Add "Preparing battle..." UI state
|
||||
|
||||
### Phase 3: Monitoring & Polish (1 week)
|
||||
|
||||
1. Add metrics and dashboards
|
||||
2. Implement alerting
|
||||
3. Cost tracking
|
||||
4. Documentation and runbooks
|
||||
|
||||
---
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Certificate Management
|
||||
|
||||
**Fully automated via Let's Encrypt + certbot.**
|
||||
|
||||
- Certbot runs on instance startup to obtain certificate
|
||||
- Systemd timer auto-renews every 60 days (certs valid 90 days)
|
||||
- No manual intervention required
|
||||
- Token rotation is manual but simple (generate new token, update both sides)
|
||||
|
||||
### Concurrent Battles
|
||||
|
||||
**Decision**: Single shared Shardok instance handles all concurrent battles.
|
||||
|
||||
Shardok already supports multiple simultaneous games. With 16 cores and activity-based spin-up, one instance is sufficient for the foreseeable future.
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
These are not needed now but documented for future consideration:
|
||||
|
||||
### Multi-Region Deployment
|
||||
|
||||
If player base expands globally:
|
||||
- Deploy Shardok instances in multiple Hetzner regions (Ashburn, Helsinki, Singapore)
|
||||
- Route players to nearest region based on their Eagle connection
|
||||
- Each region independent (no cross-region state sharing needed)
|
||||
|
||||
**Trigger**: Consistent complaints about battle latency from non-US players.
|
||||
|
||||
### Dynamic Instance Sizing
|
||||
|
||||
Could adjust instance size based on battle complexity:
|
||||
- Small battles (< 10 units): CAX21 (4 cores)
|
||||
- Medium battles: CAX31 (8 cores)
|
||||
- Large battles: CAX41 (16 cores)
|
||||
|
||||
**Current decision**: Start with CAX41 for all battles. The cost difference is minimal (~$0.02/hr between sizes) and simplicity is valuable.
|
||||
|
||||
### Dedicated Shardok Instances Per Game
|
||||
|
||||
For competitive/tournament play, could spin up dedicated instances:
|
||||
- Guaranteed resources, no contention
|
||||
- Isolated failures
|
||||
- Higher cost
|
||||
|
||||
**Current decision**: Shared instance is fine. Revisit if multiplayer competitive mode launches.
|
||||
@@ -9,6 +9,8 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/config v1.28.10
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.51
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/google/uuid v1.6.0
|
||||
google.golang.org/grpc v1.68.0
|
||||
google.golang.org/protobuf v1.36.3
|
||||
)
|
||||
|
||||
@@ -34,9 +34,13 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.33.6 h1:VwhTrsTuVn52an4mXx29PqRzs2Dv
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.6/go.mod h1:+8h7PZb3yY5ftmVLD7ocEoE98hdc8PoKS0H3wfx1dlc=
|
||||
github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
|
||||
github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -81,6 +81,84 @@ http {
|
||||
error_page 502 = /error502grpc;
|
||||
}
|
||||
|
||||
# gRPC proxy for Auth service
|
||||
location /net.eagle0.eagle.api.auth.Auth {
|
||||
# Rate limiting
|
||||
limit_req zone=grpc_limit burst=50 nodelay;
|
||||
|
||||
# gRPC proxy
|
||||
grpc_pass grpc://eagle_grpc;
|
||||
|
||||
# Timeouts
|
||||
grpc_read_timeout 30s;
|
||||
grpc_send_timeout 30s;
|
||||
|
||||
# Error handling
|
||||
error_page 502 = /error502grpc;
|
||||
}
|
||||
|
||||
# OAuth callback endpoint (proxied to Go auth service)
|
||||
location /oauth/callback {
|
||||
proxy_pass http://auth:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# 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;
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS server for Go Auth service (port 40033)
|
||||
# Clients connect here directly for OAuth RPCs in Phase 2
|
||||
server {
|
||||
listen 40033 ssl;
|
||||
http2 on;
|
||||
server_name prod.eagle0.net;
|
||||
|
||||
# SSL certificates (same as main server)
|
||||
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 Auth service
|
||||
# Uses variable-based resolution so nginx can start even if auth isn't ready yet
|
||||
# DNS is cached by the resolver directive (valid=10s)
|
||||
location /net.eagle0.eagle.api.auth.Auth {
|
||||
# Rate limiting
|
||||
limit_req zone=grpc_limit burst=50 nodelay;
|
||||
|
||||
# Dynamic upstream resolution (doesn't block nginx startup)
|
||||
set $auth_backend "auth:40033";
|
||||
grpc_pass grpc://$auth_backend;
|
||||
|
||||
# Timeouts
|
||||
grpc_read_timeout 30s;
|
||||
grpc_send_timeout 30s;
|
||||
|
||||
# Error handling
|
||||
error_page 502 = /error502grpc;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
|
||||
@@ -14,6 +14,7 @@ cc_library(
|
||||
"//src/main/cpp/net/eagle0/shardok/library:engine",
|
||||
"//src/main/cpp/net/eagle0/shardok/server:eagle_interface_grpc_server",
|
||||
"//src/main/cpp/net/eagle0/shardok/server:server_configuration",
|
||||
"//src/main/cpp/net/eagle0/shardok/server:token_auth",
|
||||
"//src/main/protobuf/net/eagle0/common:common_unit_cc_proto",
|
||||
"@grpc//:grpc++",
|
||||
],
|
||||
|
||||
@@ -34,6 +34,17 @@ cc_library(
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "token_auth",
|
||||
hdrs = ["TokenAuthInterceptor.hpp"],
|
||||
copts = COPTS,
|
||||
visibility = ["//src/main/cpp/net/eagle0/shardok:__subpackages__"],
|
||||
deps = [
|
||||
"//src/main/cpp/net/eagle0/common:protobuf_warning_suppression",
|
||||
"@grpc//:grpc++",
|
||||
],
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "eagle_interface_grpc_server",
|
||||
srcs = ["EagleInterfaceGrpcServer.cpp"],
|
||||
@@ -42,6 +53,7 @@ cc_library(
|
||||
visibility = ["//src/main/cpp/net/eagle0/shardok:__subpackages__"],
|
||||
deps = [
|
||||
":games_manager",
|
||||
":token_auth",
|
||||
"//src/main/cpp/net/eagle0/common:unit_conversions",
|
||||
"//src/main/protobuf/net/eagle0/common:shardok_internal_interface_cc_grpc",
|
||||
"//src/main/protobuf/net/eagle0/common:victory_condition_cc_proto",
|
||||
|
||||
@@ -81,8 +81,17 @@ static auto FromInternalStatus(
|
||||
|
||||
const std::string &kShardokGameRequestExtension = *(new string(".e0gr"));
|
||||
|
||||
EagleInterfaceImpl::EagleInterfaceImpl(shared_ptr<ShardokGamesManager> manager)
|
||||
: gamesManager(std::move(manager)) {}
|
||||
EagleInterfaceImpl::EagleInterfaceImpl(
|
||||
shared_ptr<ShardokGamesManager> manager,
|
||||
std::string authToken)
|
||||
: gamesManager(std::move(manager)),
|
||||
tokenValidator_(std::move(authToken)) {
|
||||
if (tokenValidator_.IsEnabled()) {
|
||||
std::cout << "Token authentication enabled" << std::endl;
|
||||
} else {
|
||||
std::cout << "Token authentication disabled (no token configured)" << std::endl;
|
||||
}
|
||||
}
|
||||
|
||||
auto ConvertPlayerInfo(
|
||||
const google::protobuf::RepeatedPtrField<net::eagle0::common::PlayerSetupInfo> &allPis,
|
||||
@@ -188,9 +197,12 @@ void EagleInterfaceImpl::StartGame(const NewGameRequest &request) {
|
||||
}
|
||||
|
||||
auto EagleInterfaceImpl::PostCommand(
|
||||
ServerContext * /*context*/,
|
||||
ServerContext *context,
|
||||
const PostCommandRequest *request,
|
||||
GameStatusResponse *response) -> Status {
|
||||
// Validate auth token
|
||||
if (auto authStatus = tokenValidator_.ValidateOrStatus(context)) { return *authStatus; }
|
||||
|
||||
shared_ptr<ShardokGameController> controller;
|
||||
try {
|
||||
controller = ControllerForGame(request->game_id(), request->game_setup_info());
|
||||
@@ -229,9 +241,12 @@ auto EagleInterfaceImpl::PostCommand(
|
||||
}
|
||||
|
||||
auto EagleInterfaceImpl::PostPlacementCommands(
|
||||
ServerContext * /*context*/,
|
||||
ServerContext *context,
|
||||
const PlacementCommandsRequest *request,
|
||||
GameStatusResponse *response) -> Status {
|
||||
// Validate auth token
|
||||
if (auto authStatus = tokenValidator_.ValidateOrStatus(context)) { return *authStatus; }
|
||||
|
||||
shared_ptr<ShardokGameController> controller;
|
||||
try {
|
||||
controller = ControllerForGame(request->game_id(), request->game_setup_info());
|
||||
@@ -323,9 +338,12 @@ void EagleInterfaceImpl::PopulateGameStatusResponse(
|
||||
}
|
||||
|
||||
auto EagleInterfaceImpl::GetHexMap(
|
||||
ServerContext * /*context*/,
|
||||
ServerContext *context,
|
||||
const HexMapRequest *request,
|
||||
HexMapResponse *response) -> Status {
|
||||
// Validate auth token
|
||||
if (auto authStatus = tokenValidator_.ValidateOrStatus(context)) { return *authStatus; }
|
||||
|
||||
// TODO: return appropriate status code if a bad map name is sent
|
||||
*response->mutable_map() = LoadMap(request->map_name());
|
||||
|
||||
@@ -333,9 +351,12 @@ auto EagleInterfaceImpl::GetHexMap(
|
||||
}
|
||||
|
||||
auto EagleInterfaceImpl::GetHexMapNames(
|
||||
ServerContext * /*context*/,
|
||||
ServerContext *context,
|
||||
const HexMapNamesRequest * /*request*/,
|
||||
HexMapNamesResponse *response) -> Status {
|
||||
// Validate auth token
|
||||
if (auto authStatus = tokenValidator_.ValidateOrStatus(context)) { return *authStatus; }
|
||||
|
||||
for (const string &mapName : GetMapNames()) { response->add_map_names(mapName); }
|
||||
|
||||
return Status::OK;
|
||||
@@ -516,6 +537,9 @@ auto EagleInterfaceImpl::SubscribeToGame(
|
||||
ServerContext *context,
|
||||
const GameSubscriptionRequest *request,
|
||||
grpc::ServerWriter<GameStatusResponse> *writer) -> Status {
|
||||
// Validate auth token
|
||||
if (auto authStatus = tokenValidator_.ValidateOrStatus(context)) { return *authStatus; }
|
||||
|
||||
shared_ptr<ShardokGameController> controller;
|
||||
try {
|
||||
controller = ControllerForGame(request->game_id(), request->game_setup_info());
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include "ShardokGamesManager.hpp"
|
||||
#include "src/main/cpp/net/eagle0/common/ProtobufWarningSuppression.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/server/ShardokGamesManager.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/server/TokenAuthInterceptor.hpp"
|
||||
#pragma GCC diagnostic push
|
||||
SUPPRESS_PROTOBUF_WARNINGS
|
||||
#include <grpc/grpc.h>
|
||||
@@ -46,6 +47,7 @@ using std::shared_ptr;
|
||||
class EagleInterfaceImpl final : public ShardokInternalInterface::Service {
|
||||
private:
|
||||
std::shared_ptr<ShardokGamesManager> gamesManager;
|
||||
TokenValidator tokenValidator_;
|
||||
|
||||
auto ControllerForGame(const GameId& gameId, const GameSetupInfo& setupInfo)
|
||||
-> std::shared_ptr<ShardokGameController>;
|
||||
@@ -57,7 +59,14 @@ private:
|
||||
GameStatusResponse* response);
|
||||
|
||||
public:
|
||||
explicit EagleInterfaceImpl(std::shared_ptr<ShardokGamesManager> manager);
|
||||
/**
|
||||
* Create the service.
|
||||
*
|
||||
* @param manager Games manager
|
||||
* @param authToken Optional auth token. If non-empty, all requests must include
|
||||
* "authorization: Bearer <token>" metadata.
|
||||
*/
|
||||
EagleInterfaceImpl(std::shared_ptr<ShardokGamesManager> manager, std::string authToken = "");
|
||||
|
||||
auto PostCommand(
|
||||
ServerContext* context,
|
||||
|
||||
@@ -24,6 +24,7 @@ const string ServerConfiguration::kEagleInterfaceGrpcAddress = "eagleInterfaceGr
|
||||
const string ServerConfiguration::kEagleGrpcAddress = "eagleGrpcAddress";
|
||||
const string ServerConfiguration::kSslCertPath = "sslCertPath";
|
||||
const string ServerConfiguration::kSslPrivateKeyPath = "sslPrivateKeyPath";
|
||||
const string ServerConfiguration::kAuthTokenPath = "authTokenPath";
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
using std::unordered_map;
|
||||
|
||||
@@ -29,6 +29,7 @@ public:
|
||||
const static string kEagleGrpcAddress;
|
||||
const static string kSslCertPath;
|
||||
const static string kSslPrivateKeyPath;
|
||||
const static string kAuthTokenPath;
|
||||
};
|
||||
|
||||
#endif /* ServerConfiguration_hpp */
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
//
|
||||
// TokenAuthInterceptor.hpp
|
||||
// eagle0
|
||||
//
|
||||
// Token-based authentication for gRPC.
|
||||
// Validates Bearer tokens in the 'authorization' metadata header.
|
||||
//
|
||||
|
||||
#ifndef TokenAuthInterceptor_hpp
|
||||
#define TokenAuthInterceptor_hpp
|
||||
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/common/ProtobufWarningSuppression.hpp"
|
||||
#pragma GCC diagnostic push
|
||||
SUPPRESS_PROTOBUF_WARNINGS
|
||||
#include <grpcpp/server_context.h>
|
||||
#include <grpcpp/support/status.h>
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
namespace shardok {
|
||||
|
||||
/**
|
||||
* Validates Bearer tokens in gRPC request metadata.
|
||||
*
|
||||
* Usage:
|
||||
* TokenValidator validator(expectedToken);
|
||||
* if (!validator.Validate(context)) {
|
||||
* return grpc::Status(grpc::UNAUTHENTICATED, "Invalid token");
|
||||
* }
|
||||
*/
|
||||
class TokenValidator {
|
||||
public:
|
||||
/**
|
||||
* Create a validator with the expected token.
|
||||
* If expectedToken is empty, all requests are allowed (no auth required).
|
||||
*/
|
||||
explicit TokenValidator(std::string expectedToken) : expectedToken_(std::move(expectedToken)) {}
|
||||
|
||||
/**
|
||||
* Validate the authorization header in the server context.
|
||||
*
|
||||
* Expects: "authorization" metadata with value "Bearer <token>"
|
||||
*
|
||||
* @return true if valid (or no auth configured), false otherwise
|
||||
*/
|
||||
[[nodiscard]] auto Validate(grpc::ServerContext* context) const -> bool {
|
||||
// If no auth token is configured, allow all requests
|
||||
if (expectedToken_.empty()) { return true; }
|
||||
|
||||
const auto& metadata = context->client_metadata();
|
||||
auto it = metadata.find("authorization");
|
||||
if (it == metadata.end()) {
|
||||
std::cerr << "Auth failed: no authorization header" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string authHeader(it->second.begin(), it->second.end());
|
||||
std::string expectedHeader = "Bearer " + expectedToken_;
|
||||
|
||||
if (authHeader != expectedHeader) {
|
||||
std::cerr << "Auth failed: invalid token" << std::endl;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and return appropriate Status.
|
||||
*
|
||||
* @return std::nullopt if valid, Status(UNAUTHENTICATED) if invalid
|
||||
*/
|
||||
[[nodiscard]] auto ValidateOrStatus(grpc::ServerContext* context) const
|
||||
-> std::optional<grpc::Status> {
|
||||
if (Validate(context)) { return std::nullopt; }
|
||||
return grpc::Status(grpc::UNAUTHENTICATED, "Invalid or missing authentication token");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if authentication is enabled.
|
||||
*/
|
||||
[[nodiscard]] auto IsEnabled() const -> bool { return !expectedToken_.empty(); }
|
||||
|
||||
private:
|
||||
std::string expectedToken_;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read auth token from a file, stripping whitespace.
|
||||
* Returns empty string if file doesn't exist or is empty.
|
||||
*/
|
||||
inline auto ReadAuthTokenFromFile(const std::string& filePath) -> std::string {
|
||||
if (filePath.empty()) { return ""; }
|
||||
|
||||
std::ifstream file(filePath);
|
||||
if (!file.is_open()) {
|
||||
std::cerr << "Warning: Could not open auth token file: " << filePath << std::endl;
|
||||
return "";
|
||||
}
|
||||
|
||||
std::stringstream buffer;
|
||||
buffer << file.rdbuf();
|
||||
std::string token = buffer.str();
|
||||
|
||||
// Trim whitespace
|
||||
auto start = token.find_first_not_of(" \t\n\r");
|
||||
if (start == std::string::npos) { return ""; }
|
||||
auto end = token.find_last_not_of(" \t\n\r");
|
||||
return token.substr(start, end - start + 1);
|
||||
}
|
||||
|
||||
} // namespace shardok
|
||||
|
||||
#endif /* TokenAuthInterceptor_hpp */
|
||||
@@ -7,7 +7,9 @@
|
||||
//
|
||||
|
||||
#include <cstddef>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <sstream>
|
||||
#include <thread>
|
||||
|
||||
#include "src/main/cpp/net/eagle0/common/ProtobufWarningSuppression.hpp"
|
||||
@@ -17,14 +19,16 @@ SUPPRESS_PROTOBUF_WARNINGS
|
||||
|
||||
#include <execinfo.h>
|
||||
#include <grpcpp/channel.h>
|
||||
#include <grpcpp/security/server_credentials.h>
|
||||
#include <signal.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
#include "server/EagleInterfaceGrpcServer.hpp"
|
||||
#include "server/ServerConfiguration.hpp"
|
||||
#include "server/ShardokGamesManager.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/server/EagleInterfaceGrpcServer.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/server/ServerConfiguration.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/server/ShardokGamesManager.hpp"
|
||||
#include "src/main/cpp/net/eagle0/shardok/server/TokenAuthInterceptor.hpp"
|
||||
|
||||
using ::GameStatePersister;
|
||||
using shardok::EagleInterfaceImpl;
|
||||
@@ -41,12 +45,15 @@ struct ServerThreadInfo {
|
||||
|
||||
auto StartInThread(grpc::ServerBuilder *serverBuilder) -> ServerThreadInfo;
|
||||
|
||||
auto CreateServerBuilder(const string &serverAddress) -> grpc::ServerBuilder *;
|
||||
auto CreateServerBuilder(const string &serverAddress, const string &certPath, const string &keyPath)
|
||||
-> grpc::ServerBuilder *;
|
||||
|
||||
auto CreateEagleInterfaceService(
|
||||
const std::shared_ptr<ServerConfiguration> &config,
|
||||
std::shared_ptr<ShardokGamesManager> shardokGamesManager) -> ServerThreadInfo;
|
||||
|
||||
auto ReadFileContents(const string &path) -> string;
|
||||
|
||||
void handler(const int sig) {
|
||||
void *array[10];
|
||||
size_t size;
|
||||
@@ -82,11 +89,46 @@ auto main(const int argc, char **argv) -> int {
|
||||
eagleInterfaceInfo.thread.join();
|
||||
}
|
||||
|
||||
auto CreateServerBuilder(const string &serverAddress) -> grpc::ServerBuilder * {
|
||||
auto ReadFileContents(const string &path) -> string {
|
||||
std::ifstream file(path);
|
||||
if (!file.is_open()) { return ""; }
|
||||
std::stringstream buffer;
|
||||
buffer << file.rdbuf();
|
||||
return buffer.str();
|
||||
}
|
||||
|
||||
auto CreateServerBuilder(const string &serverAddress, const string &certPath, const string &keyPath)
|
||||
-> grpc::ServerBuilder * {
|
||||
auto *serverBuilder = new grpc::ServerBuilder;
|
||||
|
||||
const auto credentials = grpc::InsecureServerCredentials();
|
||||
std::cout << "Creating insecure connection" << std::endl;
|
||||
std::shared_ptr<grpc::ServerCredentials> credentials;
|
||||
|
||||
// Check if TLS is configured
|
||||
if (!certPath.empty() && !keyPath.empty()) {
|
||||
const string certContents = ReadFileContents(certPath);
|
||||
const string keyContents = ReadFileContents(keyPath);
|
||||
|
||||
if (certContents.empty() || keyContents.empty()) {
|
||||
std::cerr << "Error: Could not read TLS certificate or key file" << std::endl;
|
||||
std::cerr << " cert path: " << certPath << std::endl;
|
||||
std::cerr << " key path: " << keyPath << std::endl;
|
||||
std::cerr << "Falling back to insecure connection" << std::endl;
|
||||
credentials = grpc::InsecureServerCredentials();
|
||||
} else {
|
||||
grpc::SslServerCredentialsOptions::PemKeyCertPair keyCert;
|
||||
keyCert.private_key = keyContents;
|
||||
keyCert.cert_chain = certContents;
|
||||
|
||||
grpc::SslServerCredentialsOptions sslOpts;
|
||||
sslOpts.pem_key_cert_pairs.push_back(keyCert);
|
||||
|
||||
credentials = grpc::SslServerCredentials(sslOpts);
|
||||
std::cout << "TLS enabled with certificate from: " << certPath << std::endl;
|
||||
}
|
||||
} else {
|
||||
credentials = grpc::InsecureServerCredentials();
|
||||
std::cout << "Creating insecure connection (no TLS configured)" << std::endl;
|
||||
}
|
||||
|
||||
serverBuilder->AddListeningPort(serverAddress, credentials);
|
||||
return serverBuilder;
|
||||
@@ -98,9 +140,19 @@ auto CreateEagleInterfaceService(
|
||||
const string eagleInterfaceServerAddress =
|
||||
config->stringForKey(ServerConfiguration::kEagleInterfaceGrpcAddress);
|
||||
|
||||
auto *eagleInterfaceServerBuilder = CreateServerBuilder(eagleInterfaceServerAddress);
|
||||
// TLS configuration
|
||||
const string certPath = config->stringForKey(ServerConfiguration::kSslCertPath);
|
||||
const string keyPath = config->stringForKey(ServerConfiguration::kSslPrivateKeyPath);
|
||||
|
||||
const auto eagleInterfaceService = std::make_shared<EagleInterfaceImpl>(shardokGamesManager);
|
||||
auto *eagleInterfaceServerBuilder =
|
||||
CreateServerBuilder(eagleInterfaceServerAddress, certPath, keyPath);
|
||||
|
||||
// Auth token configuration
|
||||
const string authTokenPath = config->stringForKey(ServerConfiguration::kAuthTokenPath);
|
||||
const string authToken = shardok::ReadAuthTokenFromFile(authTokenPath);
|
||||
|
||||
const auto eagleInterfaceService =
|
||||
std::make_shared<EagleInterfaceImpl>(shardokGamesManager, authToken);
|
||||
eagleInterfaceServerBuilder->RegisterService(eagleInterfaceService.get());
|
||||
|
||||
ServerThreadInfo eagleInterfaceThreadInfo = StartInThread(eagleInterfaceServerBuilder);
|
||||
|
||||
@@ -11,87 +11,106 @@ using GrpcAuthClient = Net.Eagle0.Eagle.Api.Auth.Auth.AuthClient;
|
||||
namespace Auth {
|
||||
/// <summary>
|
||||
/// gRPC client for the Auth service.
|
||||
/// Handles OAuth URL generation, code exchange, and token refresh.
|
||||
/// Routes OAuth requests (GetOAuthUrl, CheckOAuthStatus, RefreshToken) to the Go auth service.
|
||||
/// Routes user requests (SetDisplayName, GetCurrentUser, Logout) to Eagle.
|
||||
/// </summary>
|
||||
public class AuthClient : IDisposable {
|
||||
private const string RedirectUri = "eagle0://auth/callback";
|
||||
private const int PollIntervalMs = 2000; // Poll every 2 seconds
|
||||
private const int PollTimeoutMs = 300000; // 5 minute timeout
|
||||
|
||||
private readonly GrpcAuthClient _client;
|
||||
private readonly GrpcChannel _channel;
|
||||
|
||||
// State parameter for CSRF protection (stored between GetOAuthUrl and ExchangeCode)
|
||||
private string _pendingState;
|
||||
private OAuthProvider _pendingProvider;
|
||||
private readonly GrpcAuthClient _authServiceClient; // Go auth service
|
||||
private readonly GrpcAuthClient _eagleClient; // Eagle server
|
||||
private readonly GrpcChannel _authServiceChannel;
|
||||
private readonly GrpcChannel _eagleChannel;
|
||||
|
||||
/// <summary>
|
||||
/// Create auth client for the given server URL.
|
||||
/// Create auth client with separate channels for auth service and Eagle.
|
||||
/// </summary>
|
||||
/// <param name="serverUrl">Full URL with scheme, e.g. "http://localhost:40032" or
|
||||
/// "https://prod.eagle0.net"</param>
|
||||
public AuthClient(string serverUrl) {
|
||||
_channel = GrpcChannel.ForAddress(
|
||||
serverUrl,
|
||||
/// <param name="authServiceUrl">Go auth service URL, e.g.
|
||||
/// "https://prod.eagle0.net:40033"</param> <param name="eagleUrl">Eagle server URL, e.g.
|
||||
/// "https://prod.eagle0.net:40032"</param>
|
||||
public AuthClient(string authServiceUrl, string eagleUrl) {
|
||||
// Auth service channel (no JWT needed for OAuth flow)
|
||||
_authServiceChannel = GrpcChannel.ForAddress(
|
||||
authServiceUrl,
|
||||
new GrpcChannelOptions {
|
||||
HttpHandler = new YetAnotherHttpHandler { Http2Only = true },
|
||||
DisposeHttpClient = true
|
||||
});
|
||||
_authServiceClient = new GrpcAuthClient(_authServiceChannel);
|
||||
|
||||
// Create invoker with JWT interceptor for authenticated requests
|
||||
CallInvoker invoker = _channel.Intercept(new JwtAuthInterceptor());
|
||||
_client = new GrpcAuthClient(invoker);
|
||||
// Eagle channel with JWT interceptor for authenticated requests
|
||||
_eagleChannel = GrpcChannel.ForAddress(
|
||||
eagleUrl,
|
||||
new GrpcChannelOptions {
|
||||
HttpHandler = new YetAnotherHttpHandler { Http2Only = true },
|
||||
DisposeHttpClient = true
|
||||
});
|
||||
CallInvoker eagleInvoker = _eagleChannel.Intercept(new JwtAuthInterceptor());
|
||||
_eagleClient = new GrpcAuthClient(eagleInvoker);
|
||||
|
||||
Debug.Log($"[AuthClient] Configured: authService={authServiceUrl}, eagle={eagleUrl}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get OAuth URL to open in system browser.
|
||||
/// Returns both the URL and the state token for polling.
|
||||
/// Routed to Go auth service.
|
||||
/// </summary>
|
||||
/// <param name="provider">Discord or Google</param>
|
||||
/// <returns>URL to open in browser</returns>
|
||||
public async Task<string> GetOAuthUrlAsync(OAuthProvider provider) {
|
||||
var request = new GetOAuthUrlRequest { Provider = provider, RedirectUri = RedirectUri };
|
||||
/// <returns>Tuple of (URL to open in browser, state token for polling)</returns>
|
||||
public async Task<(string authUrl, string state)> GetOAuthUrlAsync(OAuthProvider provider) {
|
||||
var request = new GetOAuthUrlRequest { Provider = provider };
|
||||
|
||||
var response = await _client.GetOAuthUrlAsync(request);
|
||||
|
||||
// Store state for validation when code comes back
|
||||
_pendingState = response.State;
|
||||
_pendingProvider = provider;
|
||||
var response = await _authServiceClient.GetOAuthUrlAsync(request);
|
||||
|
||||
Debug.Log($"[AuthClient] Got OAuth URL for {provider}, state={response.State}");
|
||||
return response.AuthUrl;
|
||||
return (response.AuthUrl, response.State);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exchange authorization code for JWT tokens.
|
||||
/// Call this after receiving the OAuth callback.
|
||||
/// Poll for OAuth completion. Blocks until success, failure, or timeout.
|
||||
/// The server handles the OAuth callback and token exchange.
|
||||
/// Routed to Go auth service.
|
||||
/// </summary>
|
||||
/// <param name="code">Authorization code from callback</param>
|
||||
/// <param name="state">State parameter from callback (for CSRF validation)</param>
|
||||
/// <returns>Exchange response with tokens and user info</returns>
|
||||
public async Task<ExchangeCodeResponse> ExchangeCodeAsync(string code, string state) {
|
||||
// Validate state matches what we sent
|
||||
if (state != _pendingState) {
|
||||
throw new InvalidOperationException(
|
||||
$"State mismatch: expected {_pendingState}, got {state}. Possible CSRF attack.");
|
||||
/// <param name="state">State token from GetOAuthUrlAsync</param>
|
||||
/// <returns>Response with tokens and user info on success</returns>
|
||||
public async Task<CheckOAuthStatusResponse> PollForOAuthCompletionAsync(string state) {
|
||||
var startTime = DateTime.UtcNow;
|
||||
|
||||
while (true) {
|
||||
var request = new CheckOAuthStatusRequest { State = state };
|
||||
var response = await _authServiceClient.CheckOAuthStatusAsync(request);
|
||||
|
||||
switch (response.Status) {
|
||||
case OAuthStatus.Success:
|
||||
Debug.Log(
|
||||
$"[AuthClient] OAuth successful, user={response.User?.DisplayName}, isNew={response.IsNewUser}");
|
||||
return response;
|
||||
|
||||
case OAuthStatus.Failed:
|
||||
throw new Exception($"OAuth failed: {response.ErrorMessage}");
|
||||
|
||||
case OAuthStatus.Expired:
|
||||
throw new Exception("OAuth session expired. Please try again.");
|
||||
|
||||
case OAuthStatus.Pending:
|
||||
// Check timeout
|
||||
if ((DateTime.UtcNow - startTime).TotalMilliseconds > PollTimeoutMs) {
|
||||
throw new TimeoutException("OAuth login timed out. Please try again.");
|
||||
}
|
||||
// Wait before polling again
|
||||
await Task.Delay(PollIntervalMs);
|
||||
break;
|
||||
|
||||
default: throw new Exception($"Unknown OAuth status: {response.Status}");
|
||||
}
|
||||
}
|
||||
|
||||
var request = new ExchangeCodeRequest {
|
||||
Provider = _pendingProvider,
|
||||
AuthorizationCode = code,
|
||||
State = state,
|
||||
RedirectUri = RedirectUri
|
||||
};
|
||||
|
||||
var response = await _client.ExchangeCodeAsync(request);
|
||||
|
||||
// Clear pending state
|
||||
_pendingState = null;
|
||||
|
||||
Debug.Log(
|
||||
$"[AuthClient] Code exchange successful, user={response.User?.DisplayName}, isNew={response.IsNewUser}");
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refresh access token using stored refresh token.
|
||||
/// Routed to Go auth service.
|
||||
/// </summary>
|
||||
/// <returns>New access token and expiry, or null if refresh failed</returns>
|
||||
public async Task<RefreshTokenResponse> RefreshTokenAsync() {
|
||||
@@ -102,7 +121,7 @@ namespace Auth {
|
||||
|
||||
var request = new RefreshTokenRequest { RefreshToken = refreshToken };
|
||||
|
||||
var response = await _client.RefreshTokenAsync(request);
|
||||
var response = await _authServiceClient.RefreshTokenAsync(request);
|
||||
|
||||
// Update stored access token
|
||||
TokenStorage.UpdateAccessToken(response.AccessToken, response.ExpiresAt);
|
||||
@@ -114,14 +133,23 @@ namespace Auth {
|
||||
/// <summary>
|
||||
/// Set display name for new users.
|
||||
/// Requires valid JWT in interceptor.
|
||||
/// Routed to Eagle.
|
||||
/// </summary>
|
||||
public async Task<SetDisplayNameResponse> SetDisplayNameAsync(string displayName) {
|
||||
var request = new SetDisplayNameRequest { DisplayName = displayName };
|
||||
var response = await _client.SetDisplayNameAsync(request);
|
||||
var response = await _eagleClient.SetDisplayNameAsync(request);
|
||||
|
||||
if (response.Success) {
|
||||
TokenStorage.UpdateDisplayName(displayName);
|
||||
Debug.Log($"[AuthClient] Display name set to: {displayName}");
|
||||
// Update the access token with the new one that has updated displayName claim
|
||||
if (!string.IsNullOrEmpty(response.AccessToken)) {
|
||||
// Token expires in 7 days from now
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddDays(7).ToUnixTimeSeconds();
|
||||
TokenStorage.UpdateAccessToken(response.AccessToken, expiresAt);
|
||||
Debug.Log($"[AuthClient] Display name set to: {displayName}, token updated");
|
||||
} else {
|
||||
Debug.Log($"[AuthClient] Display name set to: {displayName}");
|
||||
}
|
||||
} else {
|
||||
Debug.LogWarning(
|
||||
$"[AuthClient] Failed to set display name: {response.ErrorMessage}");
|
||||
@@ -132,19 +160,21 @@ namespace Auth {
|
||||
|
||||
/// <summary>
|
||||
/// Get current user info. Validates the stored JWT.
|
||||
/// Routed to Eagle.
|
||||
/// </summary>
|
||||
public async Task<GetCurrentUserResponse> GetCurrentUserAsync() {
|
||||
var response = await _client.GetCurrentUserAsync(new GetCurrentUserRequest());
|
||||
var response = await _eagleClient.GetCurrentUserAsync(new GetCurrentUserRequest());
|
||||
Debug.Log($"[AuthClient] Current user: {response.User?.DisplayName}");
|
||||
return response;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logout - invalidates refresh token on server.
|
||||
/// Routed to Eagle.
|
||||
/// </summary>
|
||||
public async Task LogoutAsync() {
|
||||
try {
|
||||
await _client.LogoutAsync(new LogoutRequest());
|
||||
await _eagleClient.LogoutAsync(new LogoutRequest());
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning($"[AuthClient] Logout RPC failed (may be expected): {ex.Message}");
|
||||
}
|
||||
@@ -154,6 +184,9 @@ namespace Auth {
|
||||
Debug.Log("[AuthClient] Logged out, tokens cleared");
|
||||
}
|
||||
|
||||
public void Dispose() { _channel?.Dispose(); }
|
||||
public void Dispose() {
|
||||
_authServiceChannel?.Dispose();
|
||||
_eagleChannel?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Net.Eagle0.Eagle.Api.Auth;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Auth {
|
||||
/// <summary>
|
||||
/// Manages OAuth authentication flow including:
|
||||
/// - Opening system browser for OAuth consent
|
||||
/// - Handling deep link callbacks (eagle0://auth/callback)
|
||||
/// Manages OAuth authentication flow using server-mediated polling:
|
||||
/// - Opens system browser for OAuth consent
|
||||
/// - Polls server for OAuth completion (no deep links needed)
|
||||
/// - Token storage and refresh
|
||||
/// </summary>
|
||||
public class OAuthManager : MonoBehaviour {
|
||||
public static OAuthManager Instance { get; private set; }
|
||||
|
||||
private AuthClient _authClient;
|
||||
private string _currentServerUrl;
|
||||
private TaskCompletionSource<ExchangeCodeResponse> _pendingAuth;
|
||||
private string _currentAuthServiceUrl;
|
||||
private string _currentEagleUrl;
|
||||
|
||||
// Events for UI updates
|
||||
public event Action<UserInfo> OnLoginSuccess;
|
||||
@@ -36,36 +35,28 @@ namespace Auth {
|
||||
|
||||
Instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
// AuthClient is created lazily when SetServerUrl is called
|
||||
// AuthClient is created lazily when SetServerUrls is called
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the server URL for OAuth requests. Call this before any OAuth operations.
|
||||
/// Set the server URLs for OAuth and game requests. Call this before any OAuth operations.
|
||||
/// </summary>
|
||||
/// <param name="serverUrl">Full URL with scheme, e.g. "https://prod.eagle0.net"</param>
|
||||
public void SetServerUrl(string serverUrl) {
|
||||
if (_currentServerUrl == serverUrl && _authClient != null) {
|
||||
return; // Already configured for this URL
|
||||
/// <param name="authServiceUrl">Go auth service URL, e.g.
|
||||
/// "https://prod.eagle0.net:40033"</param> <param name="eagleUrl">Eagle server URL, e.g.
|
||||
/// "https://prod.eagle0.net:40032"</param>
|
||||
public void SetServerUrls(string authServiceUrl, string eagleUrl) {
|
||||
if (_currentAuthServiceUrl == authServiceUrl && _currentEagleUrl == eagleUrl &&
|
||||
_authClient != null) {
|
||||
return; // Already configured for these URLs
|
||||
}
|
||||
|
||||
_authClient?.Dispose();
|
||||
_currentServerUrl = serverUrl;
|
||||
_authClient = new AuthClient(serverUrl);
|
||||
Debug.Log($"[OAuthManager] Configured for server: {serverUrl}");
|
||||
_currentAuthServiceUrl = authServiceUrl;
|
||||
_currentEagleUrl = eagleUrl;
|
||||
_authClient = new AuthClient(authServiceUrl, eagleUrl);
|
||||
Debug.Log($"[OAuthManager] Configured: authService={authServiceUrl}, eagle={eagleUrl}");
|
||||
}
|
||||
|
||||
private void OnEnable() {
|
||||
// Register for deep link callbacks
|
||||
Application.deepLinkActivated += OnDeepLinkActivated;
|
||||
|
||||
// Check if app was launched via deep link
|
||||
if (!string.IsNullOrEmpty(Application.absoluteURL)) {
|
||||
OnDeepLinkActivated(Application.absoluteURL);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisable() { Application.deepLinkActivated -= OnDeepLinkActivated; }
|
||||
|
||||
private void OnDestroy() {
|
||||
_authClient?.Dispose();
|
||||
if (Instance == this) { Instance = null; }
|
||||
@@ -74,29 +65,26 @@ namespace Auth {
|
||||
private void EnsureConfigured() {
|
||||
if (_authClient == null) {
|
||||
throw new InvalidOperationException(
|
||||
"OAuthManager not configured. Call SetServerUrl() first.");
|
||||
"OAuthManager not configured. Call SetServerUrls() first.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start OAuth login with specified provider.
|
||||
/// Opens system browser for user consent.
|
||||
/// Opens system browser for user consent, then polls for completion.
|
||||
/// </summary>
|
||||
public async Task<ExchangeCodeResponse> LoginAsync(OAuthProvider provider) {
|
||||
public async Task<CheckOAuthStatusResponse> LoginAsync(OAuthProvider provider) {
|
||||
EnsureConfigured();
|
||||
try {
|
||||
// Get OAuth URL from server
|
||||
var authUrl = await _authClient.GetOAuthUrlAsync(provider);
|
||||
|
||||
// Create completion source for callback
|
||||
_pendingAuth = new TaskCompletionSource<ExchangeCodeResponse>();
|
||||
// Get OAuth URL and state from server
|
||||
var (authUrl, state) = await _authClient.GetOAuthUrlAsync(provider);
|
||||
|
||||
// Open system browser
|
||||
Debug.Log($"[OAuthManager] Opening browser: {authUrl}");
|
||||
Application.OpenURL(authUrl);
|
||||
|
||||
// Wait for deep link callback
|
||||
var response = await _pendingAuth.Task;
|
||||
// Poll for OAuth completion (server handles the callback)
|
||||
var response = await _authClient.PollForOAuthCompletionAsync(state);
|
||||
|
||||
// Store tokens
|
||||
TokenStorage.StoreTokens(
|
||||
@@ -118,44 +106,6 @@ namespace Auth {
|
||||
Debug.LogError($"[OAuthManager] Login failed: {ex.Message}");
|
||||
OnLoginFailed?.Invoke(ex.Message);
|
||||
throw;
|
||||
} finally { _pendingAuth = null; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle deep link callback from OAuth provider.
|
||||
/// URL format: eagle0://auth/callback?code=xxx&state=yyy
|
||||
/// </summary>
|
||||
private async void OnDeepLinkActivated(string url) {
|
||||
Debug.Log($"[OAuthManager] Deep link received: {url}");
|
||||
|
||||
if (!url.StartsWith("eagle0://auth/callback")) { return; }
|
||||
|
||||
try {
|
||||
// Parse query parameters
|
||||
var queryParams = ParseQueryString(url);
|
||||
|
||||
if (queryParams.TryGetValue("error", out var error)) {
|
||||
var errorDesc =
|
||||
queryParams.GetValueOrDefault("error_description", "Unknown error");
|
||||
throw new Exception($"OAuth error: {error} - {errorDesc}");
|
||||
}
|
||||
|
||||
if (!queryParams.TryGetValue("code", out var code)) {
|
||||
throw new Exception("No authorization code in callback");
|
||||
}
|
||||
|
||||
if (!queryParams.TryGetValue("state", out var state)) {
|
||||
throw new Exception("No state parameter in callback");
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
var response = await _authClient.ExchangeCodeAsync(code, state);
|
||||
|
||||
// Complete pending auth task
|
||||
_pendingAuth?.TrySetResult(response);
|
||||
} catch (Exception ex) {
|
||||
Debug.LogError($"[OAuthManager] Callback handling failed: {ex.Message}");
|
||||
_pendingAuth?.TrySetException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +124,7 @@ namespace Auth {
|
||||
/// <summary>
|
||||
/// Try to restore session from stored tokens.
|
||||
/// Returns true if valid session exists.
|
||||
/// Must call SetServerUrl() before this method.
|
||||
/// Must call SetServerUrls() before this method.
|
||||
/// </summary>
|
||||
public async Task<bool> TryRestoreSessionAsync() {
|
||||
if (_authClient == null) {
|
||||
@@ -234,27 +184,5 @@ namespace Auth {
|
||||
}
|
||||
OnLogout?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse query string from URL.
|
||||
/// </summary>
|
||||
private static Dictionary<string, string> ParseQueryString(string url) {
|
||||
var result = new Dictionary<string, string>();
|
||||
|
||||
var queryStart = url.IndexOf('?');
|
||||
if (queryStart < 0) return result;
|
||||
|
||||
var query = url.Substring(queryStart + 1);
|
||||
var pairs = query.Split('&');
|
||||
|
||||
foreach (var pair in pairs) {
|
||||
var parts = pair.Split('=');
|
||||
if (parts.Length == 2) {
|
||||
result[Uri.UnescapeDataString(parts[0])] = Uri.UnescapeDataString(parts[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+70
-6
@@ -110,6 +110,11 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
public GameObject customBattlePanel;
|
||||
public ErrorHandler errorHandler;
|
||||
|
||||
[Header("Lobby Controls")]
|
||||
public Button logoutButton;
|
||||
public TextMeshProUGUI lobbyEnvironmentText;
|
||||
public TextMeshProUGUI lobbyUserText;
|
||||
|
||||
public GameObject runningGamesListArea;
|
||||
public GameObject runningGamesListItemPrefab;
|
||||
|
||||
@@ -165,10 +170,16 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get full URL with scheme for the selected environment.
|
||||
/// Get full URL with scheme for the selected environment (Eagle server).
|
||||
/// Remote servers use HTTPS, could be extended for local HTTP.
|
||||
/// </summary>
|
||||
private string GetFullUrlFromEnvironment() { return "https://" + GetUrlFromEnvironment(); }
|
||||
private string GetEagleUrl() { return "https://" + GetUrlFromEnvironment(); }
|
||||
|
||||
/// <summary>
|
||||
/// Get full URL with scheme for the Go auth service.
|
||||
/// Auth service runs on port 40033.
|
||||
/// </summary>
|
||||
private string GetAuthServiceUrl() { return "https://" + GetUrlFromEnvironment() + ":40033"; }
|
||||
|
||||
public void ReceiveLobbyUpdate(LobbyResponse lobbyResponse) {
|
||||
_handleLobbyResponse(lobbyResponse);
|
||||
@@ -208,6 +219,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
// Initialize OAuth UI
|
||||
SetupOAuthUI();
|
||||
|
||||
// Initialize Lobby UI (logout button, etc.)
|
||||
SetupLobbyUI();
|
||||
|
||||
// Initialize status text
|
||||
UpdateConnectionStatus();
|
||||
|
||||
@@ -260,11 +274,58 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupLobbyUI() {
|
||||
if (logoutButton != null) { logoutButton.onClick.AddListener(OnLogoutClicked); }
|
||||
}
|
||||
|
||||
private void UpdateLobbyStatusDisplays() {
|
||||
// Show environment (prod/qa)
|
||||
if (lobbyEnvironmentText != null) {
|
||||
lobbyEnvironmentText.text = ConnectedEnvironmentName ?? "";
|
||||
}
|
||||
|
||||
// Show current user - prefer OAuth display name, fall back to classic login name
|
||||
if (lobbyUserText != null) {
|
||||
if (_useOAuth && !string.IsNullOrEmpty(TokenStorage.DisplayName)) {
|
||||
lobbyUserText.text = TokenStorage.DisplayName;
|
||||
} else {
|
||||
lobbyUserText.text = nameField.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnLogoutClicked() {
|
||||
Debug.Log("[ConnectionHandler] Logout button clicked");
|
||||
|
||||
// Clear OAuth tokens
|
||||
if (OAuthManager.Instance != null) { await OAuthManager.Instance.LogoutAsync(); }
|
||||
|
||||
// Disconnect from server
|
||||
_persistentClientConnection?.Dispose();
|
||||
eagleConnection?.Dispose();
|
||||
_httpClient?.Dispose();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
|
||||
_persistentClientConnection = null;
|
||||
eagleConnection = null;
|
||||
_httpClient = null;
|
||||
_cancellationTokenSource = null;
|
||||
_connectedEnvironmentIndex = -1;
|
||||
|
||||
// Return to connection screen
|
||||
gameSelectionPanel.SetActive(false);
|
||||
connectionPanel.SetActive(true);
|
||||
|
||||
// Re-initialize the auth UI
|
||||
ShowAuthPanel();
|
||||
UpdateConnectionStatus();
|
||||
}
|
||||
|
||||
private async void TryRestoreSession() {
|
||||
if (OAuthManager.Instance == null) return;
|
||||
|
||||
// Configure OAuthManager with current environment URL
|
||||
OAuthManager.Instance.SetServerUrl(GetFullUrlFromEnvironment());
|
||||
// Configure OAuthManager with auth service and Eagle URLs
|
||||
OAuthManager.Instance.SetServerUrls(GetAuthServiceUrl(), GetEagleUrl());
|
||||
|
||||
if (oauthStatusText != null) { oauthStatusText.text = "Checking for existing session..."; }
|
||||
|
||||
@@ -278,8 +339,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
}
|
||||
|
||||
private async void OnOAuthLoginClicked(OAuthProvider provider) {
|
||||
// Configure OAuthManager with current environment URL (user may have changed dropdown)
|
||||
OAuthManager.Instance.SetServerUrl(GetFullUrlFromEnvironment());
|
||||
// Configure OAuthManager with auth service and Eagle URLs (user may have changed dropdown)
|
||||
OAuthManager.Instance.SetServerUrls(GetAuthServiceUrl(), GetEagleUrl());
|
||||
|
||||
if (oauthStatusText != null) {
|
||||
oauthStatusText.text = $"Opening browser for {provider} login...";
|
||||
@@ -486,6 +547,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
connectionPanel.gameObject.SetActive(false);
|
||||
gameSelectionPanel.gameObject.SetActive(true);
|
||||
|
||||
// Update lobby status displays
|
||||
UpdateLobbyStatusDisplays();
|
||||
|
||||
// Set up running games table
|
||||
foreach (Transform row in runningGamesListArea.transform) { Destroy(row.gameObject); }
|
||||
foreach (var runningGame in lobbyResponse.RunningGames) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,6 @@ public class MainQueue : MonoBehaviour {
|
||||
// 8ms leaves room for rendering within a 16ms (60fps) frame budget
|
||||
private const long MaxMillisecondsPerFrame = 8;
|
||||
|
||||
// Track queue depth for logging
|
||||
private int _lastLoggedQueueDepth = 0;
|
||||
|
||||
private MainQueue() {}
|
||||
|
||||
void Awake() {
|
||||
@@ -38,14 +35,6 @@ public class MainQueue : MonoBehaviour {
|
||||
return;
|
||||
}
|
||||
|
||||
// Log when queue has built up (e.g., after resuming from background)
|
||||
if (queueDepthBefore > 100 && queueDepthBefore != _lastLoggedQueueDepth) {
|
||||
Debug.Log($"[MainQueue] Processing backlog: {queueDepthBefore} actions queued");
|
||||
_lastLoggedQueueDepth = queueDepthBefore;
|
||||
} else if (queueDepthBefore <= 100) {
|
||||
_lastLoggedQueueDepth = 0;
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
Action possibleAction;
|
||||
do {
|
||||
|
||||
@@ -413,6 +413,7 @@ public class HexGrid : MonoBehaviour {
|
||||
}
|
||||
|
||||
public void SetUnitInfoLabels(int cellIndex, string mainValue, string secondaryValue = null) {
|
||||
if (cells == null) return;
|
||||
cells[cellIndex].UpperUnitInfoLabel.text = mainValue;
|
||||
cells[cellIndex].LowerUnitInfoLabel.text = secondaryValue;
|
||||
|
||||
|
||||
@@ -25,10 +25,9 @@ namespace common {
|
||||
// Client that doesn't follow redirects, so we can retry each hop independently
|
||||
private readonly HttpClient _noRedirectClient;
|
||||
|
||||
// Auth header to send only to eagle0.net (not to S3 signed URLs)
|
||||
private readonly System.Net.Http.Headers.AuthenticationHeaderValue _authHeader;
|
||||
|
||||
private static readonly Uri BaseUri = new("https://eagle0.net/assets/headshots/");
|
||||
// Public CDN URL for headshots (no auth required)
|
||||
private static readonly Uri BaseUri =
|
||||
new("https://eagle0-headshots.sfo3.cdn.digitaloceanspaces.com/");
|
||||
|
||||
private const int MaxRetryAttempts = 5;
|
||||
private const int MaxRedirectHops = 5;
|
||||
@@ -73,9 +72,6 @@ namespace common {
|
||||
};
|
||||
_noRedirectClient = new HttpClient(
|
||||
handler) { Timeout = TimeSpan.FromSeconds(RequestTimeoutSeconds) };
|
||||
// Store auth header to add per-request only for eagle0.net
|
||||
// (sending it to S3 signed URLs causes HTTP 400)
|
||||
_authHeader = httpClient.DefaultRequestHeaders.Authorization;
|
||||
|
||||
// Start periodic retry timer
|
||||
_periodicRetryTimer = new Timer(
|
||||
@@ -163,10 +159,6 @@ namespace common {
|
||||
try {
|
||||
var request =
|
||||
new HttpRequestMessage { Method = HttpMethod.Get, RequestUri = uri };
|
||||
// Only send auth header to eagle0.net, not to S3 signed URLs
|
||||
if (_authHeader != null && uri.Host.EndsWith("eagle0.net")) {
|
||||
request.Headers.Authorization = _authHeader;
|
||||
}
|
||||
var response = await _noRedirectClient.SendAsync(request);
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
@@ -13,6 +15,9 @@ import (
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -122,6 +127,7 @@ func main() {
|
||||
// Set up HTTP routes
|
||||
http.HandleFunc("/", handleIndex)
|
||||
http.HandleFunc("/games", handleGamesPage)
|
||||
http.HandleFunc("/games/upload", handleGameUpload) // Must be before /games/ to match first
|
||||
http.HandleFunc("/games/", handleGameRoutes)
|
||||
http.HandleFunc("/settings", handleSettingsPage)
|
||||
http.HandleFunc("/settings/", handleSettingsRoutes)
|
||||
@@ -432,6 +438,9 @@ func handleGameRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
} else if parts[1] == "rewind" {
|
||||
// /games/{id}/rewind - rewind game to a specific action (htmx POST)
|
||||
handleRewind(w, r, gameIDHex, gameID)
|
||||
} else if parts[1] == "download" {
|
||||
// /games/{id}/download - download game save as zip
|
||||
handleGameDownload(w, r, gameIDHex, gameID)
|
||||
} else if parts[1] == "state" && len(parts) >= 3 {
|
||||
// /games/{id}/state/{index} - game state at action (htmx partial)
|
||||
actionIndex, err := strconv.Atoi(parts[2])
|
||||
@@ -671,6 +680,178 @@ func handleRewind(w http.ResponseWriter, r *http.Request, gameIDHex string, game
|
||||
}
|
||||
}
|
||||
|
||||
func handleGameDownload(w http.ResponseWriter, r *http.Request, gameIDHex string, gameID int64) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Call gRPC streaming endpoint to download the zip
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
stream, err := grpcClient.DownloadGameSave(ctx, &eagle.DownloadGameSaveRequest{
|
||||
GameId: gameID,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to start download stream for game %s: %v", gameIDHex, err)
|
||||
http.Error(w, "Failed to download game save", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Collect all chunks into a buffer
|
||||
buf := new(bytes.Buffer)
|
||||
for {
|
||||
resp, err := stream.Recv()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("Error receiving chunk for game %s: %v", gameIDHex, err)
|
||||
// Check if it's a "not found" type error
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
http.Error(w, "Game save not found", http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, "Failed to download game save", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
buf.Write(resp.Chunk)
|
||||
}
|
||||
|
||||
// Send as download
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"game_%s.zip\"", gameIDHex))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(buf.Len()))
|
||||
w.Write(buf.Bytes())
|
||||
log.Printf("Downloaded game save %s (%d bytes)", gameIDHex, buf.Len())
|
||||
}
|
||||
|
||||
// gameIDPattern matches a hex game ID from a zip filename like "game_abc123.zip"
|
||||
var gameIDPattern = regexp.MustCompile(`^game_([0-9a-fA-F]+)\.zip$`)
|
||||
|
||||
func handleGameUpload(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse multipart form (max 100MB)
|
||||
if err := r.ParseMultipartForm(100 << 20); err != nil {
|
||||
log.Printf("Failed to parse multipart form: %v", err)
|
||||
http.Error(w, "Failed to parse upload", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, header, err := r.FormFile("gamefile")
|
||||
if err != nil {
|
||||
log.Printf("Failed to get uploaded file: %v", err)
|
||||
http.Error(w, "Failed to get uploaded file", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Extract gameId from filename (expect "game_{hex}.zip")
|
||||
matches := gameIDPattern.FindStringSubmatch(header.Filename)
|
||||
if matches == nil {
|
||||
log.Printf("Invalid filename format: %s (expected game_{hex}.zip)", header.Filename)
|
||||
http.Error(w, "Invalid filename format. Expected game_{hex}.zip", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
gameIDHex := matches[1]
|
||||
|
||||
// Parse game ID to validate it
|
||||
gameIDUint, err := strconv.ParseUint(gameIDHex, 16, 64)
|
||||
if err != nil {
|
||||
log.Printf("Invalid game ID hex: %s", gameIDHex)
|
||||
http.Error(w, "Invalid game ID in filename", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract to save directory
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
log.Printf("Failed to get home directory: %v", err)
|
||||
http.Error(w, "Failed to get home directory", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
saveDir := filepath.Join(homeDir, "eagle0", "eagle", "save", gameIDHex)
|
||||
|
||||
// Create save directory
|
||||
if err := os.MkdirAll(saveDir, 0755); err != nil {
|
||||
log.Printf("Failed to create save directory %s: %v", saveDir, err)
|
||||
http.Error(w, "Failed to create save directory", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Read zip contents
|
||||
zipBytes, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
log.Printf("Failed to read uploaded file: %v", err)
|
||||
http.Error(w, "Failed to read uploaded file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Unzip to saveDir
|
||||
zipReader, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
|
||||
if err != nil {
|
||||
log.Printf("Failed to read zip file: %v", err)
|
||||
http.Error(w, "Invalid zip file", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
for _, f := range zipReader.File {
|
||||
destPath := filepath.Join(saveDir, f.Name)
|
||||
// Security check: prevent path traversal
|
||||
if !strings.HasPrefix(destPath, saveDir) {
|
||||
log.Printf("Invalid file path in zip: %s", f.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
outFile, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create file %s: %v", destPath, err)
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
outFile.Close()
|
||||
log.Printf("Failed to open zip entry %s: %v", f.Name, err)
|
||||
continue
|
||||
}
|
||||
_, err = io.Copy(outFile, rc)
|
||||
rc.Close()
|
||||
outFile.Close()
|
||||
if err != nil {
|
||||
log.Printf("Failed to extract %s: %v", f.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Call gRPC to register the game
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := grpcClient.ImportGame(ctx, &eagle.ImportGameRequest{
|
||||
GameId: int64(gameIDUint),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to import game %s via gRPC: %v", gameIDHex, err)
|
||||
http.Error(w, fmt.Sprintf("Failed to register game: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if resp.Result != eagle.ImportGameResponse_SUCCESS {
|
||||
log.Printf("Failed to import game %s: %s (result=%v)", gameIDHex, resp.ErrorMessage, resp.Result)
|
||||
http.Error(w, fmt.Sprintf("Failed to import game: %s", resp.ErrorMessage), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Successfully uploaded and imported game %s", gameIDHex)
|
||||
|
||||
// Redirect to game detail page
|
||||
http.Redirect(w, r, fmt.Sprintf("/games/%s", gameIDHex), http.StatusFound)
|
||||
}
|
||||
|
||||
// API handlers (keep JSON endpoints for programmatic access)
|
||||
|
||||
func handleGamesAPI(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<span class="status-badge {{if eq .Game.RunStatus "Running"}}running{{else}}finished{{end}}">
|
||||
{{.Game.RunStatus}}
|
||||
</span>
|
||||
<a href="/games/{{.Game.GameID}}/download" class="btn-secondary" style="margin-left: 1rem;">Download Save</a>
|
||||
</div>
|
||||
|
||||
<article>
|
||||
|
||||
@@ -4,6 +4,21 @@
|
||||
<span>{{len .Games}} game{{if ne (len .Games) 1}}s{{end}}</span>
|
||||
</div>
|
||||
|
||||
<details class="upload-section" style="margin-bottom: 1.5rem;">
|
||||
<summary style="cursor: pointer; padding: 0.5rem; background: var(--pico-secondary-background); border-radius: 4px;">
|
||||
Upload Game Save
|
||||
</summary>
|
||||
<form action="/games/upload" method="POST" enctype="multipart/form-data" style="padding: 1rem; margin-top: 0.5rem;">
|
||||
<p style="font-size: 0.9rem; color: var(--pico-muted-color);">
|
||||
Upload a game save zip file (e.g., game_abc123.zip). The game ID is extracted from the filename.
|
||||
</p>
|
||||
<div style="display: flex; gap: 1rem; align-items: center;">
|
||||
<input type="file" name="gamefile" accept=".zip" required style="flex: 1;">
|
||||
<button type="submit" class="btn-primary">Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
{{if .Games}}
|
||||
{{range .Games}}
|
||||
<article class="game-card">
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library", "go_test")
|
||||
|
||||
go_library(
|
||||
name = "authservice_lib",
|
||||
srcs = [
|
||||
"handlers.go",
|
||||
"jwt.go",
|
||||
"main.go",
|
||||
"oauth.go",
|
||||
],
|
||||
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/authservice",
|
||||
visibility = ["//visibility:private"],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:api_go_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:auth_internal_go_grpc",
|
||||
"@com_github_golang_jwt_jwt_v5//:jwt",
|
||||
"@com_github_google_uuid//:uuid",
|
||||
"@org_golang_google_grpc//:grpc",
|
||||
"@org_golang_google_grpc//codes",
|
||||
"@org_golang_google_grpc//credentials/insecure",
|
||||
"@org_golang_google_grpc//status",
|
||||
],
|
||||
)
|
||||
|
||||
go_binary(
|
||||
name = "authservice",
|
||||
embed = [":authservice_lib"],
|
||||
pure = "on",
|
||||
visibility = ["//visibility:public"],
|
||||
x_defs = {
|
||||
"main.buildTime": "{BUILD_TIMESTAMP}",
|
||||
"main.gitCommit": "{STABLE_GIT_COMMIT}",
|
||||
},
|
||||
)
|
||||
|
||||
# Linux x86_64 target for Docker deployment
|
||||
go_binary(
|
||||
name = "authservice_linux_amd64",
|
||||
embed = [":authservice_lib"],
|
||||
goarch = "amd64",
|
||||
goos = "linux",
|
||||
pure = "on",
|
||||
visibility = ["//visibility:public"],
|
||||
x_defs = {
|
||||
"main.buildTime": "{BUILD_TIMESTAMP}",
|
||||
"main.gitCommit": "{STABLE_GIT_COMMIT}",
|
||||
},
|
||||
)
|
||||
|
||||
go_test(
|
||||
name = "authservice_test",
|
||||
srcs = [
|
||||
"jwt_test.go",
|
||||
"oauth_test.go",
|
||||
],
|
||||
embed = [":authservice_lib"],
|
||||
deps = [
|
||||
"@com_github_golang_jwt_jwt_v5//:jwt",
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,195 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
auth "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/api/eagle"
|
||||
internalauth "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/internal/auth"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// AuthHandler implements the Auth gRPC service
|
||||
type AuthHandler struct {
|
||||
auth.UnimplementedAuthServer
|
||||
oauthSvc *OAuthService
|
||||
userClient internalauth.InternalUserServiceClient
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new auth handler
|
||||
func NewAuthHandler(oauthSvc *OAuthService, userClient internalauth.InternalUserServiceClient) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
oauthSvc: oauthSvc,
|
||||
userClient: userClient,
|
||||
}
|
||||
}
|
||||
|
||||
// GetOAuthUrl returns the OAuth authorization URL
|
||||
func (h *AuthHandler) GetOAuthUrl(ctx context.Context, req *auth.GetOAuthUrlRequest) (*auth.GetOAuthUrlResponse, error) {
|
||||
provider := providerToString(req.Provider)
|
||||
|
||||
authURL, state, err := h.oauthSvc.GetAuthURL(provider)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "failed to get auth URL: %v", err)
|
||||
}
|
||||
|
||||
return &auth.GetOAuthUrlResponse{
|
||||
AuthUrl: authURL,
|
||||
State: state,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CheckOAuthStatus polls for OAuth completion
|
||||
func (h *AuthHandler) CheckOAuthStatus(ctx context.Context, req *auth.CheckOAuthStatusRequest) (*auth.CheckOAuthStatusResponse, error) {
|
||||
result := h.oauthSvc.CheckStatus(req.State)
|
||||
|
||||
// Still pending
|
||||
if h.oauthSvc.IsPending(req.State) {
|
||||
return &auth.CheckOAuthStatusResponse{
|
||||
Status: auth.OAuthStatus_OAUTH_STATUS_PENDING,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Error or expired
|
||||
if result.Error != "" {
|
||||
if result.Error == "expired" {
|
||||
return &auth.CheckOAuthStatusResponse{
|
||||
Status: auth.OAuthStatus_OAUTH_STATUS_EXPIRED,
|
||||
}, nil
|
||||
}
|
||||
return &auth.CheckOAuthStatusResponse{
|
||||
Status: auth.OAuthStatus_OAUTH_STATUS_FAILED,
|
||||
ErrorMessage: result.Error,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Success - call Eagle to find/create user
|
||||
if result.Success {
|
||||
userResp, err := h.userClient.GetOrCreateUser(ctx, &internalauth.GetOrCreateUserRequest{
|
||||
Provider: result.Provider,
|
||||
ProviderUserId: result.UserInfo.ID,
|
||||
Email: result.UserInfo.Email,
|
||||
AvatarUrl: result.UserInfo.AvatarURL,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to get/create user: %v", err)
|
||||
return &auth.CheckOAuthStatusResponse{
|
||||
Status: auth.OAuthStatus_OAUTH_STATUS_FAILED,
|
||||
ErrorMessage: "Failed to create user account",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Create JWT tokens
|
||||
accessToken, err := CreateAccessToken(userResp.UserId, userResp.DisplayName, userResp.IsAdmin)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create access token: %v", err)
|
||||
return &auth.CheckOAuthStatusResponse{
|
||||
Status: auth.OAuthStatus_OAUTH_STATUS_FAILED,
|
||||
ErrorMessage: "Failed to create access token",
|
||||
}, nil
|
||||
}
|
||||
|
||||
refreshToken, err := CreateRefreshToken(userResp.UserId)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create refresh token: %v", err)
|
||||
return &auth.CheckOAuthStatusResponse{
|
||||
Status: auth.OAuthStatus_OAUTH_STATUS_FAILED,
|
||||
ErrorMessage: "Failed to create refresh token",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Access token expires in 7 days
|
||||
expiresAt := time.Now().Add(7 * 24 * time.Hour).Unix()
|
||||
|
||||
return &auth.CheckOAuthStatusResponse{
|
||||
Status: auth.OAuthStatus_OAUTH_STATUS_SUCCESS,
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresAt: expiresAt,
|
||||
User: &auth.UserInfo{
|
||||
UserId: userResp.UserId,
|
||||
DisplayName: userResp.DisplayName,
|
||||
AvatarUrl: userResp.AvatarUrl,
|
||||
Provider: stringToProvider(result.Provider),
|
||||
},
|
||||
IsNewUser: userResp.IsNewUser,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Unknown state - treat as expired
|
||||
return &auth.CheckOAuthStatusResponse{
|
||||
Status: auth.OAuthStatus_OAUTH_STATUS_EXPIRED,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetDisplayName is handled by Eagle, not this service
|
||||
func (h *AuthHandler) SetDisplayName(ctx context.Context, req *auth.SetDisplayNameRequest) (*auth.SetDisplayNameResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "SetDisplayName is handled by Eagle server")
|
||||
}
|
||||
|
||||
// RefreshToken refreshes an access token using a refresh token
|
||||
func (h *AuthHandler) RefreshToken(ctx context.Context, req *auth.RefreshTokenRequest) (*auth.RefreshTokenResponse, error) {
|
||||
// Validate the refresh token
|
||||
userID, err := ValidateRefreshToken(req.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Unauthenticated, "invalid refresh token: %v", err)
|
||||
}
|
||||
|
||||
// Get user info from Eagle
|
||||
userResp, err := h.userClient.GetUser(ctx, &internalauth.GetUserRequest{
|
||||
UserId: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to get user: %v", err)
|
||||
}
|
||||
if !userResp.Found {
|
||||
return nil, status.Errorf(codes.NotFound, "user not found")
|
||||
}
|
||||
|
||||
// Create new access token
|
||||
accessToken, err := CreateAccessToken(userResp.UserId, userResp.DisplayName, userResp.IsAdmin)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.Internal, "failed to create access token: %v", err)
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(7 * 24 * time.Hour).Unix()
|
||||
|
||||
return &auth.RefreshTokenResponse{
|
||||
AccessToken: accessToken,
|
||||
ExpiresAt: expiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetCurrentUser is handled by Eagle, not this service
|
||||
func (h *AuthHandler) GetCurrentUser(ctx context.Context, req *auth.GetCurrentUserRequest) (*auth.GetCurrentUserResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "GetCurrentUser is handled by Eagle server")
|
||||
}
|
||||
|
||||
// Logout is handled by Eagle, not this service
|
||||
func (h *AuthHandler) Logout(ctx context.Context, req *auth.LogoutRequest) (*auth.LogoutResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "Logout is handled by Eagle server")
|
||||
}
|
||||
|
||||
func providerToString(p auth.OAuthProvider) string {
|
||||
switch p {
|
||||
case auth.OAuthProvider_OAUTH_PROVIDER_DISCORD:
|
||||
return "discord"
|
||||
case auth.OAuthProvider_OAUTH_PROVIDER_GOOGLE:
|
||||
return "google"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func stringToProvider(s string) auth.OAuthProvider {
|
||||
switch s {
|
||||
case "discord":
|
||||
return auth.OAuthProvider_OAUTH_PROVIDER_DISCORD
|
||||
case "google":
|
||||
return auth.OAuthProvider_OAUTH_PROVIDER_GOOGLE
|
||||
default:
|
||||
return auth.OAuthProvider_OAUTH_PROVIDER_UNSPECIFIED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
privateKey *rsa.PrivateKey
|
||||
publicKey *rsa.PublicKey
|
||||
)
|
||||
|
||||
// EagleClaims represents the JWT claims for Eagle access tokens
|
||||
// Claim names must match what Eagle's JwtServiceImpl expects:
|
||||
// - Subject (sub) = userId
|
||||
// - name = displayName
|
||||
// - admin = isAdmin (only if true)
|
||||
type EagleClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
Name string `json:"name,omitempty"`
|
||||
IsAdmin bool `json:"admin,omitempty"`
|
||||
}
|
||||
|
||||
// RefreshClaims represents the JWT claims for refresh tokens
|
||||
type RefreshClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
// UserID is stored in Subject (sub) claim
|
||||
}
|
||||
|
||||
const (
|
||||
accessTokenDuration = 7 * 24 * time.Hour // 7 days
|
||||
refreshTokenDuration = 30 * 24 * time.Hour // 30 days
|
||||
)
|
||||
|
||||
// loadKeys loads the RSA keys from PEM files
|
||||
func loadKeys(privatePath, publicPath string) error {
|
||||
// Load private key
|
||||
privateKeyBytes, err := os.ReadFile(privatePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read private key: %w", err)
|
||||
}
|
||||
|
||||
privateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse private key: %w", err)
|
||||
}
|
||||
|
||||
// Load public key
|
||||
publicKeyBytes, err := os.ReadFile(publicPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read public key: %w", err)
|
||||
}
|
||||
|
||||
publicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse public key: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// bootstrapKeysFromJWK converts JWT_PRIVATE_KEY (JWK format) to PEM files if they don't exist.
|
||||
// Uses only Go stdlib - no third-party JWK libraries needed.
|
||||
func bootstrapKeysFromJWK(jwkJSON, privatePath, publicPath string) error {
|
||||
// Parse JWK JSON
|
||||
var jwk struct {
|
||||
N string `json:"n"` // modulus
|
||||
E string `json:"e"` // public exponent
|
||||
D string `json:"d"` // private exponent
|
||||
P string `json:"p"` // first prime
|
||||
Q string `json:"q"` // second prime
|
||||
Dp string `json:"dp"` // d mod (p-1)
|
||||
Dq string `json:"dq"` // d mod (q-1)
|
||||
Qi string `json:"qi"` // q^-1 mod p
|
||||
}
|
||||
if err := json.Unmarshal([]byte(jwkJSON), &jwk); err != nil {
|
||||
return fmt.Errorf("failed to parse JWK JSON: %w", err)
|
||||
}
|
||||
|
||||
// Decode base64url values to big.Int
|
||||
n, err := base64urlToBigInt(jwk.N)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode modulus: %w", err)
|
||||
}
|
||||
e, err := base64urlToBigInt(jwk.E)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode exponent: %w", err)
|
||||
}
|
||||
d, err := base64urlToBigInt(jwk.D)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode private exponent: %w", err)
|
||||
}
|
||||
p, err := base64urlToBigInt(jwk.P)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode prime p: %w", err)
|
||||
}
|
||||
q, err := base64urlToBigInt(jwk.Q)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode prime q: %w", err)
|
||||
}
|
||||
dp, err := base64urlToBigInt(jwk.Dp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode dp: %w", err)
|
||||
}
|
||||
dq, err := base64urlToBigInt(jwk.Dq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode dq: %w", err)
|
||||
}
|
||||
qi, err := base64urlToBigInt(jwk.Qi)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode qi: %w", err)
|
||||
}
|
||||
|
||||
// Construct RSA private key
|
||||
rsaKey := &rsa.PrivateKey{
|
||||
PublicKey: rsa.PublicKey{
|
||||
N: n,
|
||||
E: int(e.Int64()),
|
||||
},
|
||||
D: d,
|
||||
Primes: []*big.Int{p, q},
|
||||
Precomputed: rsa.PrecomputedValues{
|
||||
Dp: dp,
|
||||
Dq: dq,
|
||||
Qinv: qi,
|
||||
},
|
||||
}
|
||||
|
||||
// Validate the key
|
||||
if err := rsaKey.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid RSA key: %w", err)
|
||||
}
|
||||
|
||||
// Encode private key to PKCS#8 PEM
|
||||
privateKeyBytes, err := x509.MarshalPKCS8PrivateKey(rsaKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal private key: %w", err)
|
||||
}
|
||||
privatePEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PRIVATE KEY",
|
||||
Bytes: privateKeyBytes,
|
||||
})
|
||||
if err := os.WriteFile(privatePath, privatePEM, 0600); err != nil {
|
||||
return fmt.Errorf("failed to write private key: %w", err)
|
||||
}
|
||||
|
||||
// Encode public key to X.509 PEM
|
||||
publicKeyBytes, err := x509.MarshalPKIXPublicKey(&rsaKey.PublicKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal public key: %w", err)
|
||||
}
|
||||
publicPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PUBLIC KEY",
|
||||
Bytes: publicKeyBytes,
|
||||
})
|
||||
if err := os.WriteFile(publicPath, publicPEM, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write public key: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// base64urlToBigInt decodes a base64url or standard base64 string to a big.Int
|
||||
// JWK spec uses base64url without padding, but some libraries use standard base64
|
||||
func base64urlToBigInt(s string) (*big.Int, error) {
|
||||
// Try base64url without padding first (JWK standard)
|
||||
b, err := base64.RawURLEncoding.DecodeString(s)
|
||||
if err == nil {
|
||||
return new(big.Int).SetBytes(b), nil
|
||||
}
|
||||
|
||||
// Try base64url with padding
|
||||
b, err = base64.URLEncoding.DecodeString(addPadding(s))
|
||||
if err == nil {
|
||||
return new(big.Int).SetBytes(b), nil
|
||||
}
|
||||
|
||||
// Try standard base64 without padding
|
||||
b, err = base64.RawStdEncoding.DecodeString(s)
|
||||
if err == nil {
|
||||
return new(big.Int).SetBytes(b), nil
|
||||
}
|
||||
|
||||
// Try standard base64 with padding
|
||||
b, err = base64.StdEncoding.DecodeString(addPadding(s))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode base64: %w", err)
|
||||
}
|
||||
return new(big.Int).SetBytes(b), nil
|
||||
}
|
||||
|
||||
// addPadding adds base64 padding if needed
|
||||
func addPadding(s string) string {
|
||||
switch len(s) % 4 {
|
||||
case 2:
|
||||
return s + "=="
|
||||
case 3:
|
||||
return s + "="
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// CreateAccessToken creates a new JWT access token
|
||||
func CreateAccessToken(userID, displayName string, isAdmin bool) (string, error) {
|
||||
if privateKey == nil {
|
||||
return "", fmt.Errorf("private key not loaded")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
claims := EagleClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: userID, // Eagle reads userId from "sub" claim
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(accessTokenDuration)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
Issuer: "eagle0",
|
||||
},
|
||||
Name: displayName,
|
||||
IsAdmin: isAdmin,
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
return token.SignedString(privateKey)
|
||||
}
|
||||
|
||||
// CreateRefreshToken creates a new refresh token
|
||||
func CreateRefreshToken(userID string) (string, error) {
|
||||
if privateKey == nil {
|
||||
return "", fmt.Errorf("private key not loaded")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
tokenID := uuid.New().String()
|
||||
|
||||
claims := RefreshClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: tokenID,
|
||||
Subject: userID,
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(refreshTokenDuration)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
Issuer: "eagle0",
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
return token.SignedString(privateKey)
|
||||
}
|
||||
|
||||
// ValidateRefreshToken validates a refresh token and returns the user ID
|
||||
func ValidateRefreshToken(tokenString string) (string, error) {
|
||||
if publicKey == nil {
|
||||
return "", fmt.Errorf("public key not loaded")
|
||||
}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenString, &RefreshClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return publicKey, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse token: %w", err)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*RefreshClaims)
|
||||
if !ok || !token.Valid {
|
||||
return "", fmt.Errorf("invalid token claims")
|
||||
}
|
||||
|
||||
return claims.Subject, nil
|
||||
}
|
||||
|
||||
// HashRefreshToken creates a hash of a refresh token for storage
|
||||
func HashRefreshToken(token string) string {
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// setupTestKeys generates temporary RSA keys for testing
|
||||
func setupTestKeys(t *testing.T) (string, func()) {
|
||||
t.Helper()
|
||||
|
||||
// Generate a test RSA key pair
|
||||
testPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate test private key: %v", err)
|
||||
}
|
||||
|
||||
// Create temp directory
|
||||
tempDir, err := os.MkdirTemp("", "jwt_test")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
|
||||
// Write private key
|
||||
privateKeyPath := filepath.Join(tempDir, "private.pem")
|
||||
privateKeyBytes := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(testPrivateKey),
|
||||
})
|
||||
if err := os.WriteFile(privateKeyPath, privateKeyBytes, 0600); err != nil {
|
||||
os.RemoveAll(tempDir)
|
||||
t.Fatalf("Failed to write private key: %v", err)
|
||||
}
|
||||
|
||||
// Write public key
|
||||
publicKeyPath := filepath.Join(tempDir, "public.pem")
|
||||
publicKeyBytes, err := x509.MarshalPKIXPublicKey(&testPrivateKey.PublicKey)
|
||||
if err != nil {
|
||||
os.RemoveAll(tempDir)
|
||||
t.Fatalf("Failed to marshal public key: %v", err)
|
||||
}
|
||||
publicKeyPEM := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PUBLIC KEY",
|
||||
Bytes: publicKeyBytes,
|
||||
})
|
||||
if err := os.WriteFile(publicKeyPath, publicKeyPEM, 0644); err != nil {
|
||||
os.RemoveAll(tempDir)
|
||||
t.Fatalf("Failed to write public key: %v", err)
|
||||
}
|
||||
|
||||
// Load the keys
|
||||
if err := loadKeys(privateKeyPath, publicKeyPath); err != nil {
|
||||
os.RemoveAll(tempDir)
|
||||
t.Fatalf("Failed to load keys: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
os.RemoveAll(tempDir)
|
||||
privateKey = nil
|
||||
publicKey = nil
|
||||
}
|
||||
|
||||
return tempDir, cleanup
|
||||
}
|
||||
|
||||
func TestCreateAccessToken(t *testing.T) {
|
||||
_, cleanup := setupTestKeys(t)
|
||||
defer cleanup()
|
||||
|
||||
token, err := CreateAccessToken("user-123", "TestUser", false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccessToken failed: %v", err)
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
t.Error("Expected non-empty token")
|
||||
}
|
||||
|
||||
// Parse and validate the token
|
||||
parsed, err := jwt.ParseWithClaims(token, &EagleClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return publicKey, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse token: %v", err)
|
||||
}
|
||||
|
||||
claims, ok := parsed.Claims.(*EagleClaims)
|
||||
if !ok {
|
||||
t.Fatal("Failed to cast claims")
|
||||
}
|
||||
|
||||
if claims.Subject != "user-123" {
|
||||
t.Errorf("Expected Subject 'user-123', got '%s'", claims.Subject)
|
||||
}
|
||||
if claims.Name != "TestUser" {
|
||||
t.Errorf("Expected Name 'TestUser', got '%s'", claims.Name)
|
||||
}
|
||||
if claims.IsAdmin != false {
|
||||
t.Error("Expected IsAdmin to be false")
|
||||
}
|
||||
if claims.Issuer != "eagle0" {
|
||||
t.Errorf("Expected Issuer 'eagle0', got '%s'", claims.Issuer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAccessToken_Admin(t *testing.T) {
|
||||
_, cleanup := setupTestKeys(t)
|
||||
defer cleanup()
|
||||
|
||||
token, err := CreateAccessToken("admin-456", "AdminUser", true)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccessToken failed: %v", err)
|
||||
}
|
||||
|
||||
parsed, err := jwt.ParseWithClaims(token, &EagleClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return publicKey, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse token: %v", err)
|
||||
}
|
||||
|
||||
claims := parsed.Claims.(*EagleClaims)
|
||||
if claims.IsAdmin != true {
|
||||
t.Error("Expected IsAdmin to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAccessToken_Expiration(t *testing.T) {
|
||||
_, cleanup := setupTestKeys(t)
|
||||
defer cleanup()
|
||||
|
||||
token, err := CreateAccessToken("user-123", "TestUser", false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccessToken failed: %v", err)
|
||||
}
|
||||
|
||||
parsed, err := jwt.ParseWithClaims(token, &EagleClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return publicKey, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse token: %v", err)
|
||||
}
|
||||
|
||||
claims := parsed.Claims.(*EagleClaims)
|
||||
|
||||
// Check expiration is approximately 7 days from now
|
||||
expectedExpiry := time.Now().Add(7 * 24 * time.Hour)
|
||||
actualExpiry := claims.ExpiresAt.Time
|
||||
|
||||
// Allow 1 minute tolerance
|
||||
diff := actualExpiry.Sub(expectedExpiry)
|
||||
if diff < -time.Minute || diff > time.Minute {
|
||||
t.Errorf("Token expiry not within expected range. Expected ~%v, got %v", expectedExpiry, actualExpiry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRefreshToken(t *testing.T) {
|
||||
_, cleanup := setupTestKeys(t)
|
||||
defer cleanup()
|
||||
|
||||
token, err := CreateRefreshToken("user-123")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRefreshToken failed: %v", err)
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
t.Error("Expected non-empty token")
|
||||
}
|
||||
|
||||
// Validate the refresh token
|
||||
userID, err := ValidateRefreshToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateRefreshToken failed: %v", err)
|
||||
}
|
||||
|
||||
if userID != "user-123" {
|
||||
t.Errorf("Expected UserID 'user-123', got '%s'", userID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRefreshToken_InvalidToken(t *testing.T) {
|
||||
_, cleanup := setupTestKeys(t)
|
||||
defer cleanup()
|
||||
|
||||
_, err := ValidateRefreshToken("invalid-token")
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRefreshToken_WrongSignature(t *testing.T) {
|
||||
_, cleanup := setupTestKeys(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create a token with a different key
|
||||
otherKey, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
claims := RefreshClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: "user-123",
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
Issuer: "eagle0",
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
tokenString, _ := token.SignedString(otherKey)
|
||||
|
||||
_, err := ValidateRefreshToken(tokenString)
|
||||
if err == nil {
|
||||
t.Error("Expected error for token with wrong signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashRefreshToken(t *testing.T) {
|
||||
hash1 := HashRefreshToken("token123")
|
||||
hash2 := HashRefreshToken("token123")
|
||||
hash3 := HashRefreshToken("token456")
|
||||
|
||||
if hash1 != hash2 {
|
||||
t.Error("Same input should produce same hash")
|
||||
}
|
||||
|
||||
if hash1 == hash3 {
|
||||
t.Error("Different inputs should produce different hashes")
|
||||
}
|
||||
|
||||
if len(hash1) != 64 { // SHA256 produces 64 hex characters
|
||||
t.Errorf("Expected hash length 64, got %d", len(hash1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAccessToken_NoPrivateKey(t *testing.T) {
|
||||
// Ensure keys are nil
|
||||
privateKey = nil
|
||||
publicKey = nil
|
||||
|
||||
_, err := CreateAccessToken("user-123", "TestUser", false)
|
||||
if err == nil {
|
||||
t.Error("Expected error when private key is not loaded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRefreshToken_NoPrivateKey(t *testing.T) {
|
||||
privateKey = nil
|
||||
publicKey = nil
|
||||
|
||||
_, err := CreateRefreshToken("user-123")
|
||||
if err == nil {
|
||||
t.Error("Expected error when private key is not loaded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRefreshToken_NoPublicKey(t *testing.T) {
|
||||
privateKey = nil
|
||||
publicKey = nil
|
||||
|
||||
_, err := ValidateRefreshToken("some-token")
|
||||
if err == nil {
|
||||
t.Error("Expected error when public key is not loaded")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Package main provides the OAuth authentication service for Eagle.
|
||||
// It handles OAuth flows and issues JWT tokens, calling Eagle's internal
|
||||
// UserService for user management.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
auth "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/api/eagle"
|
||||
internalauth "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/internal/auth"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
var (
|
||||
grpcPort = flag.Int("grpc-port", 40033, "gRPC server port")
|
||||
httpPort = flag.Int("http-port", 8080, "HTTP server port for OAuth callbacks")
|
||||
eagleInternalURL = flag.String("eagle-internal-url", "localhost:40034", "Eagle internal gRPC address")
|
||||
privateKeyPath = flag.String("private-key", "/etc/eagle0/keys/private.pem", "Path to RSA private key")
|
||||
publicKeyPath = flag.String("public-key", "/etc/eagle0/keys/public.pem", "Path to RSA public key")
|
||||
)
|
||||
|
||||
// getEnvInt reads an int from environment variable or returns the flag default
|
||||
func getEnvInt(key string, flagValue *int) int {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
if n, err := fmt.Sscanf(v, "%d", flagValue); n == 1 && err == nil {
|
||||
return *flagValue
|
||||
}
|
||||
}
|
||||
return *flagValue
|
||||
}
|
||||
|
||||
// getEnvString reads a string from environment variable or returns the flag default
|
||||
func getEnvString(key string, flagValue *string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return *flagValue
|
||||
}
|
||||
|
||||
// Build info variables - set via ldflags
|
||||
var (
|
||||
buildTime = "unknown"
|
||||
gitCommit = "unknown"
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
log.Printf("Eagle Auth Service starting (build: %s, commit: %s)", buildTime, gitCommit)
|
||||
|
||||
// Read config from environment variables (with flag defaults as fallback)
|
||||
actualGRPCPort := getEnvInt("AUTH_GRPC_PORT", grpcPort)
|
||||
actualHTTPPort := getEnvInt("AUTH_HTTP_PORT", httpPort)
|
||||
actualEagleURL := getEnvString("EAGLE_INTERNAL_ADDRESS", eagleInternalURL)
|
||||
|
||||
// JWT keys path can be a directory or explicit paths
|
||||
keysPath := os.Getenv("JWT_KEYS_PATH")
|
||||
actualPrivateKeyPath := *privateKeyPath
|
||||
actualPublicKeyPath := *publicKeyPath
|
||||
if keysPath != "" {
|
||||
actualPrivateKeyPath = keysPath + "/private.pem"
|
||||
actualPublicKeyPath = keysPath + "/public.pem"
|
||||
}
|
||||
|
||||
// Try to load RSA keys from PEM files
|
||||
err := loadKeys(actualPrivateKeyPath, actualPublicKeyPath)
|
||||
if err != nil {
|
||||
// PEM files don't exist - try to bootstrap from JWT_PRIVATE_KEY env var (JWK format)
|
||||
jwkJSON := os.Getenv("JWT_PRIVATE_KEY")
|
||||
if jwkJSON == "" {
|
||||
log.Fatalf("Failed to load RSA keys: %v (and JWT_PRIVATE_KEY not set for bootstrap)", err)
|
||||
}
|
||||
|
||||
log.Printf("PEM files not found, bootstrapping from JWT_PRIVATE_KEY...")
|
||||
if err := bootstrapKeysFromJWK(jwkJSON, actualPrivateKeyPath, actualPublicKeyPath); err != nil {
|
||||
log.Fatalf("Failed to bootstrap keys from JWK: %v", err)
|
||||
}
|
||||
log.Printf("Created PEM files from JWT_PRIVATE_KEY")
|
||||
|
||||
// Now load the newly created PEM files
|
||||
if err := loadKeys(actualPrivateKeyPath, actualPublicKeyPath); err != nil {
|
||||
log.Fatalf("Failed to load bootstrapped RSA keys: %v", err)
|
||||
}
|
||||
}
|
||||
log.Printf("Loaded RSA keys from %s", actualPrivateKeyPath)
|
||||
|
||||
// Connect to Eagle's internal UserService
|
||||
eagleConn, err := grpc.Dial(actualEagleURL,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to Eagle internal service: %v", err)
|
||||
}
|
||||
defer eagleConn.Close()
|
||||
|
||||
userClient := internalauth.NewInternalUserServiceClient(eagleConn)
|
||||
log.Printf("Connected to Eagle internal service at %s", actualEagleURL)
|
||||
|
||||
// Create OAuth service
|
||||
oauthSvc := NewOAuthService(getOAuthConfigs())
|
||||
|
||||
// Create auth service handler
|
||||
authHandler := NewAuthHandler(oauthSvc, userClient)
|
||||
|
||||
// Start gRPC server
|
||||
grpcLis, err := net.Listen("tcp", fmt.Sprintf(":%d", actualGRPCPort))
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to listen on gRPC port: %v", err)
|
||||
}
|
||||
|
||||
grpcServer := grpc.NewServer()
|
||||
auth.RegisterAuthServer(grpcServer, authHandler)
|
||||
log.Printf("gRPC server listening on port %d", actualGRPCPort)
|
||||
|
||||
go func() {
|
||||
if err := grpcServer.Serve(grpcLis); err != nil {
|
||||
log.Fatalf("gRPC server failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Start HTTP server for OAuth callbacks
|
||||
http.HandleFunc("/oauth/callback", oauthSvc.HandleCallback)
|
||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, "OK")
|
||||
})
|
||||
|
||||
log.Printf("HTTP server listening on port %d", actualHTTPPort)
|
||||
if err := http.ListenAndServe(fmt.Sprintf(":%d", actualHTTPPort), nil); err != nil {
|
||||
log.Fatalf("HTTP server failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// getOAuthConfigs reads OAuth configuration from environment variables
|
||||
func getOAuthConfigs() map[string]OAuthProviderConfig {
|
||||
return map[string]OAuthProviderConfig{
|
||||
"discord": {
|
||||
ClientID: os.Getenv("DISCORD_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("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: []string{"identify", "email"},
|
||||
},
|
||||
"google": {
|
||||
ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("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: []string{"openid", "email", "profile"},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// OAuthProviderConfig holds configuration for an OAuth provider
|
||||
type OAuthProviderConfig struct {
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
AuthorizationEndpoint string
|
||||
TokenEndpoint string
|
||||
UserInfoEndpoint string
|
||||
Scopes []string
|
||||
}
|
||||
|
||||
// OAuthState represents a pending OAuth session
|
||||
type OAuthState struct {
|
||||
Provider string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ProviderUserInfo holds user info from an OAuth provider
|
||||
type ProviderUserInfo struct {
|
||||
ID string
|
||||
Email string
|
||||
AvatarURL string
|
||||
Username string
|
||||
}
|
||||
|
||||
// OAuthResult represents the result of an OAuth flow
|
||||
type OAuthResult struct {
|
||||
Success bool
|
||||
UserInfo *ProviderUserInfo
|
||||
Provider string
|
||||
Error string
|
||||
}
|
||||
|
||||
// OAuthService manages OAuth state and handles callbacks
|
||||
type OAuthService struct {
|
||||
configs map[string]OAuthProviderConfig
|
||||
callbackURL string
|
||||
pendingOAuth sync.Map // state -> OAuthState
|
||||
completedOAuth sync.Map // state -> OAuthResult
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
const stateExpiration = 10 * time.Minute
|
||||
|
||||
// NewOAuthService creates a new OAuth service
|
||||
func NewOAuthService(configs map[string]OAuthProviderConfig) *OAuthService {
|
||||
callbackURL := getCallbackURL()
|
||||
log.Printf("OAuth: using callback URL: %s", callbackURL)
|
||||
|
||||
svc := &OAuthService{
|
||||
configs: configs,
|
||||
callbackURL: callbackURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
// Start cleanup goroutine
|
||||
go svc.cleanupLoop()
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
func getCallbackURL() string {
|
||||
// Explicit callback URL takes precedence
|
||||
if url := os.Getenv("OAUTH_CALLBACK_URL"); url != "" {
|
||||
return url
|
||||
}
|
||||
// Construct from SERVER_BASE_URL (matches Scala behavior)
|
||||
if baseURL := os.Getenv("SERVER_BASE_URL"); baseURL != "" {
|
||||
return baseURL + "/oauth/callback"
|
||||
}
|
||||
return "http://localhost:8080/oauth/callback"
|
||||
}
|
||||
|
||||
// GetAuthURL generates an OAuth authorization URL
|
||||
func (s *OAuthService) GetAuthURL(provider string) (authURL string, state string, err error) {
|
||||
config, ok := s.configs[provider]
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("unsupported provider: %s", provider)
|
||||
}
|
||||
|
||||
state = uuid.New().String()
|
||||
s.pendingOAuth.Store(state, OAuthState{
|
||||
Provider: provider,
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
|
||||
log.Printf("OAuth: created state=%s for provider=%s", state, provider)
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("client_id", config.ClientID)
|
||||
params.Set("redirect_uri", s.callbackURL)
|
||||
params.Set("response_type", "code")
|
||||
params.Set("scope", strings.Join(config.Scopes, " "))
|
||||
params.Set("state", state)
|
||||
|
||||
authURL = config.AuthorizationEndpoint + "?" + params.Encode()
|
||||
return authURL, state, nil
|
||||
}
|
||||
|
||||
// CheckStatus checks the status of an OAuth flow
|
||||
func (s *OAuthService) CheckStatus(state string) OAuthResult {
|
||||
// Check completed results first
|
||||
if result, ok := s.completedOAuth.Load(state); ok {
|
||||
return result.(OAuthResult)
|
||||
}
|
||||
|
||||
// Check if still pending
|
||||
if stateData, ok := s.pendingOAuth.Load(state); ok {
|
||||
oauthState := stateData.(OAuthState)
|
||||
if time.Since(oauthState.CreatedAt) < stateExpiration {
|
||||
return OAuthResult{} // Pending - empty result
|
||||
}
|
||||
// Expired
|
||||
s.pendingOAuth.Delete(state)
|
||||
return OAuthResult{Error: "expired"}
|
||||
}
|
||||
|
||||
// Unknown state
|
||||
return OAuthResult{Error: "expired"}
|
||||
}
|
||||
|
||||
// IsPending returns true if the state is still pending
|
||||
func (s *OAuthService) IsPending(state string) bool {
|
||||
if stateData, ok := s.pendingOAuth.Load(state); ok {
|
||||
oauthState := stateData.(OAuthState)
|
||||
return time.Since(oauthState.CreatedAt) < stateExpiration
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HandleCallback handles the OAuth callback from the provider
|
||||
func (s *OAuthService) HandleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
errorParam := r.URL.Query().Get("error")
|
||||
|
||||
log.Printf("OAuth callback: state=%s, hasCode=%v, error=%s", state, code != "", errorParam)
|
||||
|
||||
if errorParam != "" {
|
||||
s.completeWithError(state, fmt.Sprintf("OAuth error: %s", errorParam))
|
||||
http.Error(w, "OAuth failed: "+errorParam, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if code == "" || state == "" {
|
||||
http.Error(w, "Missing code or state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get pending OAuth info
|
||||
stateData, ok := s.pendingOAuth.Load(state)
|
||||
if !ok {
|
||||
log.Printf("OAuth callback: state=%s not found in pending", state)
|
||||
http.Error(w, "Invalid or expired state", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
oauthState := stateData.(OAuthState)
|
||||
if time.Since(oauthState.CreatedAt) >= stateExpiration {
|
||||
s.pendingOAuth.Delete(state)
|
||||
s.completeWithError(state, "OAuth session expired")
|
||||
http.Error(w, "OAuth session expired", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange code for token
|
||||
config := s.configs[oauthState.Provider]
|
||||
accessToken, err := s.exchangeCodeForToken(config, code)
|
||||
if err != nil {
|
||||
log.Printf("OAuth callback: token exchange failed: %v", err)
|
||||
s.completeWithError(state, err.Error())
|
||||
http.Error(w, "Token exchange failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch user info
|
||||
userInfo, err := s.fetchUserInfo(oauthState.Provider, config, accessToken)
|
||||
if err != nil {
|
||||
log.Printf("OAuth callback: user info fetch failed: %v", err)
|
||||
s.completeWithError(state, err.Error())
|
||||
http.Error(w, "Failed to fetch user info", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Mark as completed
|
||||
s.pendingOAuth.Delete(state)
|
||||
s.completedOAuth.Store(state, OAuthResult{
|
||||
Success: true,
|
||||
UserInfo: userInfo,
|
||||
Provider: oauthState.Provider,
|
||||
})
|
||||
|
||||
log.Printf("OAuth callback: SUCCESS state=%s user=%s", state, userInfo.Username)
|
||||
|
||||
// Return success page
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Login Successful</title></head>
|
||||
<body>
|
||||
<h1>Login Successful!</h1>
|
||||
<p>You can close this window and return to the game.</p>
|
||||
<script>window.close();</script>
|
||||
</body>
|
||||
</html>`)
|
||||
}
|
||||
|
||||
func (s *OAuthService) completeWithError(state string, errMsg string) {
|
||||
s.pendingOAuth.Delete(state)
|
||||
s.completedOAuth.Store(state, OAuthResult{
|
||||
Success: false,
|
||||
Error: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *OAuthService) exchangeCodeForToken(config OAuthProviderConfig, code string) (string, error) {
|
||||
data := url.Values{}
|
||||
data.Set("client_id", config.ClientID)
|
||||
data.Set("client_secret", config.ClientSecret)
|
||||
data.Set("grant_type", "authorization_code")
|
||||
data.Set("code", code)
|
||||
data.Set("redirect_uri", s.callbackURL)
|
||||
|
||||
req, err := http.NewRequest("POST", config.TokenEndpoint, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("token exchange failed: %d - %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return tokenResp.AccessToken, nil
|
||||
}
|
||||
|
||||
func (s *OAuthService) fetchUserInfo(provider string, config OAuthProviderConfig, accessToken string) (*ProviderUserInfo, error) {
|
||||
req, err := http.NewRequest("GET", config.UserInfoEndpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("user info fetch failed: %d - %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return s.parseUserInfo(provider, body)
|
||||
}
|
||||
|
||||
func (s *OAuthService) parseUserInfo(provider string, data []byte) (*ProviderUserInfo, error) {
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch provider {
|
||||
case "discord":
|
||||
id, _ := result["id"].(string)
|
||||
username, _ := result["username"].(string)
|
||||
email, _ := result["email"].(string)
|
||||
avatar, _ := result["avatar"].(string)
|
||||
|
||||
avatarURL := ""
|
||||
if avatar != "" {
|
||||
avatarURL = fmt.Sprintf("https://cdn.discordapp.com/avatars/%s/%s.png", id, avatar)
|
||||
}
|
||||
|
||||
return &ProviderUserInfo{
|
||||
ID: id,
|
||||
Username: username,
|
||||
Email: email,
|
||||
AvatarURL: avatarURL,
|
||||
}, nil
|
||||
|
||||
case "google":
|
||||
id, _ := result["id"].(string)
|
||||
name, _ := result["name"].(string)
|
||||
email, _ := result["email"].(string)
|
||||
picture, _ := result["picture"].(string)
|
||||
|
||||
return &ProviderUserInfo{
|
||||
ID: id,
|
||||
Username: name,
|
||||
Email: email,
|
||||
AvatarURL: picture,
|
||||
}, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported provider: %s", provider)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OAuthService) cleanupLoop() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
for range ticker.C {
|
||||
s.cleanupExpiredStates()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *OAuthService) cleanupExpiredStates() {
|
||||
cutoff := time.Now().Add(-stateExpiration)
|
||||
|
||||
s.pendingOAuth.Range(func(key, value interface{}) bool {
|
||||
state := value.(OAuthState)
|
||||
if state.CreatedAt.Before(cutoff) {
|
||||
s.pendingOAuth.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// Keep completed results for a bit longer (2x expiration)
|
||||
completedCutoff := time.Now().Add(-2 * stateExpiration)
|
||||
s.completedOAuth.Range(func(key, value interface{}) bool {
|
||||
// We don't have a timestamp on completed results, so we check if
|
||||
// the corresponding pending state exists and is old
|
||||
if _, ok := s.pendingOAuth.Load(key); !ok {
|
||||
// No pending state, check if it's been a while
|
||||
// For simplicity, delete completed results that aren't claimed within 2x expiration
|
||||
s.completedOAuth.Delete(key)
|
||||
}
|
||||
_ = completedCutoff // suppress unused warning
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewOAuthService(t *testing.T) {
|
||||
configs := map[string]OAuthProviderConfig{
|
||||
"discord": {
|
||||
ClientID: "test-client-id",
|
||||
ClientSecret: "test-client-secret",
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewOAuthService(configs)
|
||||
if svc == nil {
|
||||
t.Fatal("Expected non-nil OAuthService")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthURL(t *testing.T) {
|
||||
// Set callback URL for test
|
||||
os.Setenv("OAUTH_CALLBACK_URL", "https://test.eagle0.net/oauth/callback")
|
||||
defer os.Unsetenv("OAUTH_CALLBACK_URL")
|
||||
|
||||
configs := map[string]OAuthProviderConfig{
|
||||
"discord": {
|
||||
ClientID: "test-client-id",
|
||||
ClientSecret: "test-client-secret",
|
||||
AuthorizationEndpoint: "https://discord.com/api/oauth2/authorize",
|
||||
Scopes: []string{"identify", "email"},
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewOAuthService(configs)
|
||||
|
||||
url, state, err := svc.GetAuthURL("discord")
|
||||
if err != nil {
|
||||
t.Fatalf("GetAuthURL failed: %v", err)
|
||||
}
|
||||
|
||||
if url == "" {
|
||||
t.Error("Expected non-empty URL")
|
||||
}
|
||||
if state == "" {
|
||||
t.Error("Expected non-empty state")
|
||||
}
|
||||
|
||||
// Verify state is stored as pending
|
||||
if !svc.IsPending(state) {
|
||||
t.Error("State should be pending after GetAuthURL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthURL_UnknownProvider(t *testing.T) {
|
||||
configs := map[string]OAuthProviderConfig{}
|
||||
svc := NewOAuthService(configs)
|
||||
|
||||
_, _, err := svc.GetAuthURL("unknown")
|
||||
if err == nil {
|
||||
t.Error("Expected error for unknown provider")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAuthURL_EmptyClientID(t *testing.T) {
|
||||
// Note: The current implementation doesn't validate empty ClientID
|
||||
// It will still generate a URL, just with an empty client_id parameter
|
||||
configs := map[string]OAuthProviderConfig{
|
||||
"discord": {
|
||||
ClientID: "",
|
||||
ClientSecret: "test-secret",
|
||||
AuthorizationEndpoint: "https://discord.com/api/oauth2/authorize",
|
||||
},
|
||||
}
|
||||
svc := NewOAuthService(configs)
|
||||
|
||||
url, state, err := svc.GetAuthURL("discord")
|
||||
// No error expected - the provider exists, it just has empty client ID
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if url == "" {
|
||||
t.Error("Expected URL to be generated")
|
||||
}
|
||||
if state == "" {
|
||||
t.Error("Expected state to be generated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPending(t *testing.T) {
|
||||
configs := map[string]OAuthProviderConfig{
|
||||
"discord": {
|
||||
ClientID: "test-client-id",
|
||||
ClientSecret: "test-client-secret",
|
||||
AuthorizationEndpoint: "https://discord.com/api/oauth2/authorize",
|
||||
},
|
||||
}
|
||||
svc := NewOAuthService(configs)
|
||||
|
||||
// Unknown state should not be pending
|
||||
if svc.IsPending("unknown-state") {
|
||||
t.Error("Unknown state should not be pending")
|
||||
}
|
||||
|
||||
// Create a pending state
|
||||
_, state, _ := svc.GetAuthURL("discord")
|
||||
|
||||
if !svc.IsPending(state) {
|
||||
t.Error("State should be pending after GetAuthURL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatus_Pending(t *testing.T) {
|
||||
configs := map[string]OAuthProviderConfig{
|
||||
"discord": {
|
||||
ClientID: "test-client-id",
|
||||
ClientSecret: "test-client-secret",
|
||||
AuthorizationEndpoint: "https://discord.com/api/oauth2/authorize",
|
||||
},
|
||||
}
|
||||
svc := NewOAuthService(configs)
|
||||
|
||||
_, state, _ := svc.GetAuthURL("discord")
|
||||
|
||||
result := svc.CheckStatus(state)
|
||||
if result.Success {
|
||||
t.Error("Expected Success to be false for pending state")
|
||||
}
|
||||
if result.Error != "" {
|
||||
t.Errorf("Expected no error for pending state, got: %s", result.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatus_Unknown(t *testing.T) {
|
||||
configs := map[string]OAuthProviderConfig{}
|
||||
svc := NewOAuthService(configs)
|
||||
|
||||
result := svc.CheckStatus("unknown-state")
|
||||
if result.Success {
|
||||
t.Error("Expected Success to be false for unknown state")
|
||||
}
|
||||
if result.Error != "expired" {
|
||||
t.Errorf("Expected 'expired' error for unknown state, got: %s", result.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatus_Completed(t *testing.T) {
|
||||
configs := map[string]OAuthProviderConfig{
|
||||
"discord": {
|
||||
ClientID: "test-client-id",
|
||||
ClientSecret: "test-client-secret",
|
||||
AuthorizationEndpoint: "https://discord.com/api/oauth2/authorize",
|
||||
},
|
||||
}
|
||||
svc := NewOAuthService(configs)
|
||||
|
||||
// Create a pending state
|
||||
_, state, _ := svc.GetAuthURL("discord")
|
||||
|
||||
// Simulate completion by directly storing in completedOAuth
|
||||
testResult := OAuthResult{
|
||||
Success: true,
|
||||
Provider: "discord",
|
||||
UserInfo: &ProviderUserInfo{
|
||||
ID: "user-123",
|
||||
Email: "test@example.com",
|
||||
},
|
||||
}
|
||||
svc.completedOAuth.Store(state, testResult)
|
||||
svc.pendingOAuth.Delete(state)
|
||||
|
||||
result := svc.CheckStatus(state)
|
||||
if !result.Success {
|
||||
t.Error("Expected Success to be true for completed state")
|
||||
}
|
||||
if result.Provider != "discord" {
|
||||
t.Errorf("Expected provider 'discord', got '%s'", result.Provider)
|
||||
}
|
||||
if result.UserInfo.ID != "user-123" {
|
||||
t.Errorf("Expected user ID 'user-123', got '%s'", result.UserInfo.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckStatus_Error(t *testing.T) {
|
||||
configs := map[string]OAuthProviderConfig{
|
||||
"discord": {
|
||||
ClientID: "test-client-id",
|
||||
ClientSecret: "test-client-secret",
|
||||
AuthorizationEndpoint: "https://discord.com/api/oauth2/authorize",
|
||||
},
|
||||
}
|
||||
svc := NewOAuthService(configs)
|
||||
|
||||
// Create a pending state
|
||||
_, state, _ := svc.GetAuthURL("discord")
|
||||
|
||||
// Simulate error by storing error result
|
||||
errorResult := OAuthResult{
|
||||
Success: false,
|
||||
Error: "access_denied",
|
||||
}
|
||||
svc.completedOAuth.Store(state, errorResult)
|
||||
svc.pendingOAuth.Delete(state)
|
||||
|
||||
result := svc.CheckStatus(state)
|
||||
if result.Success {
|
||||
t.Error("Expected Success to be false for error result")
|
||||
}
|
||||
if result.Error != "access_denied" {
|
||||
t.Errorf("Expected error 'access_denied', got '%s'", result.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingOAuthExpiry(t *testing.T) {
|
||||
configs := map[string]OAuthProviderConfig{
|
||||
"discord": {
|
||||
ClientID: "test-client-id",
|
||||
ClientSecret: "test-client-secret",
|
||||
AuthorizationEndpoint: "https://discord.com/api/oauth2/authorize",
|
||||
},
|
||||
}
|
||||
svc := NewOAuthService(configs)
|
||||
|
||||
_, state, _ := svc.GetAuthURL("discord")
|
||||
|
||||
// Verify the pending entry has a creation time
|
||||
value, ok := svc.pendingOAuth.Load(state)
|
||||
if !ok {
|
||||
t.Fatal("Expected pending entry to exist")
|
||||
}
|
||||
|
||||
pending := value.(OAuthState)
|
||||
if pending.CreatedAt.After(time.Now()) {
|
||||
t.Error("CreatedAt should be in the past or present")
|
||||
}
|
||||
if time.Since(pending.CreatedAt) > time.Second {
|
||||
t.Error("CreatedAt should be very recent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderUserInfo(t *testing.T) {
|
||||
info := ProviderUserInfo{
|
||||
ID: "12345",
|
||||
Email: "user@example.com",
|
||||
AvatarURL: "https://example.com/avatar.png",
|
||||
}
|
||||
|
||||
if info.ID != "12345" {
|
||||
t.Errorf("Expected ID '12345', got '%s'", info.ID)
|
||||
}
|
||||
if info.Email != "user@example.com" {
|
||||
t.Errorf("Expected Email 'user@example.com', got '%s'", info.Email)
|
||||
}
|
||||
if info.AvatarURL != "https://example.com/avatar.png" {
|
||||
t.Errorf("Expected AvatarURL, got '%s'", info.AvatarURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthResult(t *testing.T) {
|
||||
// Test successful result
|
||||
successResult := OAuthResult{
|
||||
Success: true,
|
||||
Provider: "google",
|
||||
UserInfo: &ProviderUserInfo{ID: "user-1"},
|
||||
}
|
||||
if !successResult.Success {
|
||||
t.Error("Expected Success to be true")
|
||||
}
|
||||
if successResult.Provider != "google" {
|
||||
t.Errorf("Expected provider 'google', got '%s'", successResult.Provider)
|
||||
}
|
||||
|
||||
// Test error result
|
||||
errorResult := OAuthResult{
|
||||
Success: false,
|
||||
Error: "token_expired",
|
||||
}
|
||||
if errorResult.Success {
|
||||
t.Error("Expected Success to be false")
|
||||
}
|
||||
if errorResult.Error != "token_expired" {
|
||||
t.Errorf("Expected error 'token_expired', got '%s'", errorResult.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiplePendingStates(t *testing.T) {
|
||||
configs := map[string]OAuthProviderConfig{
|
||||
"discord": {
|
||||
ClientID: "test-client-id",
|
||||
ClientSecret: "test-client-secret",
|
||||
AuthorizationEndpoint: "https://discord.com/api/oauth2/authorize",
|
||||
},
|
||||
}
|
||||
svc := NewOAuthService(configs)
|
||||
|
||||
// Create multiple pending states
|
||||
_, state1, _ := svc.GetAuthURL("discord")
|
||||
_, state2, _ := svc.GetAuthURL("discord")
|
||||
_, state3, _ := svc.GetAuthURL("discord")
|
||||
|
||||
// All should be unique
|
||||
if state1 == state2 || state2 == state3 || state1 == state3 {
|
||||
t.Error("States should be unique")
|
||||
}
|
||||
|
||||
// All should be pending
|
||||
if !svc.IsPending(state1) || !svc.IsPending(state2) || !svc.IsPending(state3) {
|
||||
t.Error("All states should be pending")
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,13 @@ option objc_class_prefix = "E0A";
|
||||
|
||||
// Authentication service for OAuth login
|
||||
service Auth {
|
||||
// Get OAuth URL to open in system browser
|
||||
// Get OAuth URL to open in system browser.
|
||||
// The server handles the OAuth callback - client polls CheckOAuthStatus.
|
||||
rpc GetOAuthUrl(GetOAuthUrlRequest) returns (GetOAuthUrlResponse) {}
|
||||
|
||||
// Exchange authorization code for JWT tokens
|
||||
rpc ExchangeCode(ExchangeCodeRequest) returns (ExchangeCodeResponse) {}
|
||||
// Poll for OAuth completion. Call this after opening the URL from GetOAuthUrl.
|
||||
// The server receives the OAuth callback and exchanges the code for tokens.
|
||||
rpc CheckOAuthStatus(CheckOAuthStatusRequest) returns (CheckOAuthStatusResponse) {}
|
||||
|
||||
// Set or update display name (requires valid JWT)
|
||||
rpc SetDisplayName(SetDisplayNameRequest) returns (SetDisplayNameResponse) {}
|
||||
@@ -42,28 +44,36 @@ enum OAuthProvider {
|
||||
// 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
|
||||
string state = 2; // State parameter - use this to poll CheckOAuthStatus
|
||||
}
|
||||
|
||||
// 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
|
||||
// Poll for OAuth completion status
|
||||
message CheckOAuthStatusRequest {
|
||||
string state = 1; // State from GetOAuthUrlResponse
|
||||
}
|
||||
|
||||
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
|
||||
message CheckOAuthStatusResponse {
|
||||
OAuthStatus status = 1;
|
||||
// Only set when status is OAUTH_STATUS_SUCCESS:
|
||||
string access_token = 2;
|
||||
string refresh_token = 3;
|
||||
int64 expires_at = 4;
|
||||
UserInfo user = 5;
|
||||
bool is_new_user = 6;
|
||||
// Only set when status is OAUTH_STATUS_FAILED:
|
||||
string error_message = 7;
|
||||
}
|
||||
|
||||
enum OAuthStatus {
|
||||
OAUTH_STATUS_UNSPECIFIED = 0;
|
||||
OAUTH_STATUS_PENDING = 1; // User hasn't completed OAuth yet
|
||||
OAUTH_STATUS_SUCCESS = 2; // OAuth complete, tokens available
|
||||
OAUTH_STATUS_FAILED = 3; // OAuth failed (user denied, etc.)
|
||||
OAUTH_STATUS_EXPIRED = 4; // State token expired (typically 10 minutes)
|
||||
}
|
||||
|
||||
// User information returned from auth endpoints
|
||||
@@ -83,6 +93,7 @@ message SetDisplayNameResponse {
|
||||
bool success = 1;
|
||||
string error_message = 2; // If not success: "Name taken", "Invalid characters", etc.
|
||||
UserInfo user = 3;
|
||||
string access_token = 4; // New JWT with updated displayName claim
|
||||
}
|
||||
|
||||
// Refresh access token
|
||||
|
||||
@@ -41,6 +41,8 @@ service Eagle {
|
||||
rpc ConvertHumanToAi(ConvertHumanToAiRequest) returns (ConvertHumanToAiResponse) {}
|
||||
rpc ReassignFaction(ReassignFactionRequest) returns (ReassignFactionResponse) {}
|
||||
rpc RewindGame(RewindGameRequest) returns (RewindGameResponse) {}
|
||||
rpc ImportGame(ImportGameRequest) returns (ImportGameResponse) {}
|
||||
rpc DownloadGameSave(DownloadGameSaveRequest) returns (stream DownloadGameSaveResponse) {}
|
||||
}
|
||||
|
||||
message PostCommandRequest {
|
||||
@@ -554,3 +556,26 @@ message RewindGameResponse {
|
||||
int32 new_action_count = 3;
|
||||
int32 resynced_clients = 4; // Number of clients that were resynced with the new state
|
||||
}
|
||||
|
||||
message ImportGameRequest {
|
||||
int64 game_id = 1;
|
||||
}
|
||||
|
||||
message ImportGameResponse {
|
||||
enum Result {
|
||||
SUCCESS = 0;
|
||||
GAME_NOT_FOUND = 1;
|
||||
ALREADY_RUNNING = 2;
|
||||
INVALID_SAVE = 3;
|
||||
}
|
||||
Result result = 1;
|
||||
string error_message = 2;
|
||||
}
|
||||
|
||||
message DownloadGameSaveRequest {
|
||||
int64 game_id = 1;
|
||||
}
|
||||
|
||||
message DownloadGameSaveResponse {
|
||||
bytes chunk = 1; // Chunk of zip file data
|
||||
}
|
||||
|
||||
@@ -783,6 +783,34 @@ proto_library(
|
||||
deps = ["@com_google_protobuf//:timestamp_proto"],
|
||||
)
|
||||
|
||||
# Internal auth service for Go auth service to call Eagle
|
||||
scala_proto_library(
|
||||
name = "auth_internal_scala_grpc",
|
||||
visibility = [
|
||||
"//src/main/scala/net/eagle0/eagle:__subpackages__",
|
||||
"//src/test/scala/net/eagle0/eagle:__subpackages__",
|
||||
],
|
||||
deps = [":auth_internal_proto"],
|
||||
)
|
||||
|
||||
proto_library(
|
||||
name = "auth_internal_proto",
|
||||
srcs = ["auth_internal.proto"],
|
||||
visibility = [
|
||||
"//src/main/go/net/eagle0:__subpackages__",
|
||||
],
|
||||
)
|
||||
|
||||
# Uses go_grpc (v1) which generates both messages and gRPC code
|
||||
# (go_grpc_v2 only generates gRPC code, not the message types)
|
||||
go_proto_library(
|
||||
name = "auth_internal_go_grpc",
|
||||
compilers = ["@io_bazel_rules_go//proto:go_grpc"], # keep
|
||||
importpath = "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/internal/auth",
|
||||
proto = ":auth_internal_proto",
|
||||
visibility = ["//src/main/go/net/eagle0:__subpackages__"],
|
||||
)
|
||||
|
||||
go_proto_library(
|
||||
name = "internal_go_proto",
|
||||
importpath = "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/internal",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//
|
||||
// Copyright 2026 Dan Crosby
|
||||
//
|
||||
|
||||
syntax = "proto3";
|
||||
|
||||
package net.eagle0.eagle.internal.auth;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "net.eagle0.eagle.internal.auth";
|
||||
option go_package = "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/internal/auth";
|
||||
|
||||
// Internal service for user management, called by the Go auth service.
|
||||
// This is NOT exposed to clients - only for internal container-to-container communication.
|
||||
service InternalUserService {
|
||||
// Find or create a user from OAuth provider info.
|
||||
// Called by Go auth service after successful OAuth callback.
|
||||
rpc GetOrCreateUser(GetOrCreateUserRequest) returns (GetOrCreateUserResponse) {}
|
||||
|
||||
// Get user by internal user ID.
|
||||
// Used to look up user info for JWT claims.
|
||||
rpc GetUser(GetUserRequest) returns (GetUserResponse) {}
|
||||
}
|
||||
|
||||
// Request to find or create a user from OAuth provider info
|
||||
message GetOrCreateUserRequest {
|
||||
string provider = 1; // "discord" or "google"
|
||||
string provider_user_id = 2; // ID from the OAuth provider
|
||||
string email = 3; // Email from provider
|
||||
string avatar_url = 4; // Avatar URL from provider
|
||||
}
|
||||
|
||||
message GetOrCreateUserResponse {
|
||||
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
|
||||
bool is_admin = 4; // Admin flag for impersonation support
|
||||
bool is_new_user = 5; // True if this user was just created
|
||||
string provider = 6; // The provider used for this login
|
||||
}
|
||||
|
||||
// Request to get user by ID
|
||||
message GetUserRequest {
|
||||
string user_id = 1; // Internal Eagle0 user ID (UUID)
|
||||
}
|
||||
|
||||
message GetUserResponse {
|
||||
bool found = 1; // True if user exists
|
||||
string user_id = 2; // Internal Eagle0 user ID (UUID)
|
||||
string display_name = 3; // Player-chosen display name
|
||||
string avatar_url = 4; // Profile picture URL
|
||||
bool is_admin = 5; // Admin flag
|
||||
string provider = 6; // Primary OAuth provider
|
||||
}
|
||||
@@ -9,6 +9,7 @@ scala_binary(
|
||||
":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:auth_internal_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",
|
||||
@@ -20,7 +21,10 @@ scala_binary(
|
||||
"//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:external_auth_client",
|
||||
"//src/main/scala/net/eagle0/eagle/service:games_manager",
|
||||
"//src/main/scala/net/eagle0/eagle/service:internal_user_service",
|
||||
"//src/main/scala/net/eagle0/eagle/service:oauth_http_handler",
|
||||
"//src/main/scala/net/eagle0/eagle/service:server_setup_helpers",
|
||||
"//src/main/scala/net/eagle0/eagle/service/persistence:async_s3_persister",
|
||||
"//src/main/scala/net/eagle0/eagle/service/persistence:local_file_persister",
|
||||
|
||||
@@ -8,7 +8,8 @@ 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.auth.{JwtService, JwtServiceImpl, OAuthService, OAuthServiceImpl, UserService, UserServiceImpl}
|
||||
import net.eagle0.eagle.internal.auth.auth_internal.InternalUserServiceGrpc
|
||||
import net.eagle0.eagle.service.*
|
||||
import net.eagle0.eagle.service.persistence.{AsyncS3Persister, LocalFilePersister, SaveDirectory}
|
||||
|
||||
@@ -25,6 +26,19 @@ object Main {
|
||||
|
||||
private val gptModelNameKey = Symbol("gptModelName")
|
||||
|
||||
private val serverBaseUrlKey = Symbol("serverBaseUrl")
|
||||
private val defaultServerBaseUrl = "https://prod.eagle0.net"
|
||||
|
||||
private val oauthHttpPortKey = Symbol("oauthHttpPort")
|
||||
private val defaultOauthHttpPort = "8080"
|
||||
|
||||
// External Go auth service address (optional - if set, OAuth is forwarded to Go)
|
||||
private val authServiceUrlKey = Symbol("authServiceUrl")
|
||||
|
||||
// Internal gRPC port for Go auth service to call back (InternalUserService)
|
||||
private val internalGrpcPortKey = Symbol("internalGrpcPort")
|
||||
private val defaultInternalGrpcPort = "40034"
|
||||
|
||||
def parsedOptions(args: Array[String]): Map[Symbol, String] = {
|
||||
def nextOption(
|
||||
map: Map[Symbol, String],
|
||||
@@ -43,6 +57,26 @@ object Main {
|
||||
map ++ Map(gptModelNameKey -> value),
|
||||
tail
|
||||
)
|
||||
case "--server-base-url" +: value +: tail =>
|
||||
nextOption(
|
||||
map ++ Map(serverBaseUrlKey -> value),
|
||||
tail
|
||||
)
|
||||
case "--oauth-http-port" +: value +: tail =>
|
||||
nextOption(
|
||||
map ++ Map(oauthHttpPortKey -> value),
|
||||
tail
|
||||
)
|
||||
case "--auth-service-url" +: value +: tail =>
|
||||
nextOption(
|
||||
map ++ Map(authServiceUrlKey -> value),
|
||||
tail
|
||||
)
|
||||
case "--internal-grpc-port" +: value +: tail =>
|
||||
nextOption(
|
||||
map ++ Map(internalGrpcPortKey -> value),
|
||||
tail
|
||||
)
|
||||
case option +: _ =>
|
||||
throw new IllegalArgumentException("Unknown option " + option)
|
||||
|
||||
@@ -77,11 +111,59 @@ object Main {
|
||||
val customBattleManager = newCustomBattleManager(shardokInterfaceAddress)
|
||||
customBattleManager.begin()
|
||||
|
||||
val serverBaseUrl =
|
||||
options.getOrElse(serverBaseUrlKey, defaultServerBaseUrl)
|
||||
|
||||
// Create OAuth service (shared between gRPC and HTTP)
|
||||
val oauthService = new OAuthServiceImpl(serverBaseUrl)
|
||||
|
||||
// Initialize auth services
|
||||
val authPersister = LocalFilePersister(SaveDirectory.saveDirectory)
|
||||
val userService = new UserServiceImpl(authPersister)
|
||||
|
||||
// Check if external auth service is configured
|
||||
val authServiceUrl: Option[String] = options.get(authServiceUrlKey)
|
||||
|
||||
// Start OAuth HTTP server only if NOT using external auth service
|
||||
if authServiceUrl.isEmpty then {
|
||||
val oauthHttpPort =
|
||||
options.getOrElse(oauthHttpPortKey, defaultOauthHttpPort).toInt
|
||||
val oauthHttpHandler = new OAuthHttpHandler(oauthService, oauthHttpPort)
|
||||
oauthHttpHandler.start()
|
||||
}
|
||||
|
||||
// Start internal gRPC server if external auth service is configured
|
||||
val internalServer: Option[Server] = authServiceUrl.map { _ =>
|
||||
val internalGrpcPort =
|
||||
options.getOrElse(internalGrpcPortKey, defaultInternalGrpcPort).toInt
|
||||
val internalServerBuilder = ServerBuilder.forPort(internalGrpcPort)
|
||||
internalServerBuilder.addService(
|
||||
InternalUserServiceGrpc.bindService(
|
||||
InternalUserServiceImpl(userService),
|
||||
ExecutionContext.global
|
||||
)
|
||||
)
|
||||
val server = internalServerBuilder.build.start
|
||||
SimpleTimedLogger.printLogger.logLine(
|
||||
s"Internal gRPC server (InternalUserService) listening on port $internalGrpcPort"
|
||||
)
|
||||
server
|
||||
}
|
||||
|
||||
// Create external auth client if configured
|
||||
val externalAuthClient: Option[ExternalAuthClient] = authServiceUrl.map { url =>
|
||||
SimpleTimedLogger.printLogger.logLine(s"Using external auth service at $url")
|
||||
ExternalAuthClient(url)(ExecutionContext.global)
|
||||
}
|
||||
|
||||
val server = buildServer(
|
||||
gamesManager,
|
||||
customBattleManager,
|
||||
options.getOrElse(eagleGrpcPortKey, defaultEagleGrpcPort).toInt,
|
||||
SeededRandom(random.nextLong())
|
||||
SeededRandom(random.nextLong()),
|
||||
oauthService,
|
||||
userService,
|
||||
externalAuthClient
|
||||
).start
|
||||
SimpleTimedLogger.printLogger.logLine(
|
||||
s"Eagle server is listening on port ${server.getPort}"
|
||||
@@ -94,15 +176,15 @@ object Main {
|
||||
gamesManager: GamesManager,
|
||||
customBattleManager: CustomBattleManager,
|
||||
grpcPort: Int,
|
||||
functionalRandom: FunctionalRandom
|
||||
functionalRandom: FunctionalRandom,
|
||||
oauthService: OAuthService,
|
||||
userService: UserService,
|
||||
externalAuthClient: Option[ExternalAuthClient]
|
||||
): Server = {
|
||||
val serverBuilder = ServerBuilder.forPort(grpcPort)
|
||||
|
||||
// Initialize auth services
|
||||
// Initialize JWT service
|
||||
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(
|
||||
@@ -118,11 +200,17 @@ object Main {
|
||||
serverBuilder.addService(
|
||||
AuthGrpc
|
||||
.bindService(
|
||||
AuthServiceImpl(oauthService, userService, jwt),
|
||||
AuthServiceImpl(oauthService, userService, jwt, externalAuthClient),
|
||||
ExecutionContext.global
|
||||
)
|
||||
)
|
||||
SimpleTimedLogger.printLogger.logLine("OAuth auth service enabled")
|
||||
if externalAuthClient.isDefined then {
|
||||
SimpleTimedLogger.printLogger.logLine(
|
||||
"OAuth auth service enabled (proxying to external Go auth service)"
|
||||
)
|
||||
} else {
|
||||
SimpleTimedLogger.printLogger.logLine("OAuth auth service enabled")
|
||||
}
|
||||
}
|
||||
|
||||
if jwtService.isEmpty then {
|
||||
|
||||
@@ -7,16 +7,14 @@ import net.eagle0.eagle.{FactionId, GameId, ProvinceId}
|
||||
import net.eagle0.eagle.api.available_command.AvailableCommand
|
||||
import net.eagle0.eagle.api.command.OneProvinceAvailableCommands
|
||||
import net.eagle0.eagle.api.selected_command.SelectedCommand
|
||||
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
|
||||
import net.eagle0.eagle.library.util.*
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.{
|
||||
AlmsCommandSelector,
|
||||
AttackDecisionCommandChooser,
|
||||
CommandChooser
|
||||
}
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.*
|
||||
import net.eagle0.eagle.library.Engine
|
||||
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
|
||||
case class AIClientWithSelectedCommand(
|
||||
client: AIClient,
|
||||
@@ -55,13 +53,13 @@ case class AIClient(
|
||||
else
|
||||
chooseFrom(
|
||||
maybeCommandsMap.get.commandsByProvince,
|
||||
GameStateConverter.toProto(engine.currentState),
|
||||
engine.currentState,
|
||||
functionalRandom
|
||||
)
|
||||
}
|
||||
|
||||
private def chooseMidGameCommandFrom(
|
||||
gameState: GameStateProto,
|
||||
gameState: GameState,
|
||||
oneProvinceAvailableCommands: OneProvinceAvailableCommands,
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[AIClientWithSelectedCommand] =
|
||||
@@ -82,7 +80,7 @@ case class AIClient(
|
||||
fr
|
||||
) =>
|
||||
logger.logLine(
|
||||
s"${gameState.currentDate.get.year} ${gameState.currentDate.get.month} ${gameState.currentPhase}"
|
||||
s"${gameState.currentDate.map(_.year).getOrElse("?")} ${gameState.currentDate.map(_.month).getOrElse("?")} ${gameState.currentPhase}"
|
||||
)
|
||||
logger.logLine(
|
||||
s"$actingProvinceId (${gameState.provinces(actingProvinceId).name})"
|
||||
@@ -116,7 +114,7 @@ case class AIClient(
|
||||
|
||||
private def chooseFrom(
|
||||
acs: Map[ProvinceId, OneProvinceAvailableCommands],
|
||||
gs: GameStateProto,
|
||||
gs: GameState,
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[AIClientWithSelectedCommand] = {
|
||||
val opac = acs.head._2
|
||||
@@ -151,7 +149,7 @@ case class AIClient(
|
||||
(actingFactionId, gameState, availableCommands, functionalRandom) =>
|
||||
AttackDecisionCommandChooser.attackDecisionSelectedCommand(
|
||||
actingFactionId,
|
||||
GameStateConverter.fromProto(gameState),
|
||||
gameState,
|
||||
availableCommands,
|
||||
functionalRandom
|
||||
),
|
||||
@@ -187,7 +185,7 @@ case class AIClient(
|
||||
),
|
||||
(
|
||||
fid: FactionId,
|
||||
gs: GameStateProto,
|
||||
gameState: GameState,
|
||||
acs: Vector[AvailableCommand],
|
||||
functionalRandom
|
||||
) =>
|
||||
@@ -195,7 +193,7 @@ case class AIClient(
|
||||
AlmsCommandSelector
|
||||
.chosenAlmsForSupportWithMinimumCommandForProvince(
|
||||
actingFactionId = fid,
|
||||
gameState = GameStateConverter.fromProto(gs),
|
||||
gameState = gameState,
|
||||
availableCommands = acs,
|
||||
minimumFood = 0,
|
||||
provinceId = opac.provinceId
|
||||
|
||||
@@ -1,49 +1,35 @@
|
||||
package net.eagle0.eagle.ai
|
||||
|
||||
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
|
||||
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
|
||||
import net.eagle0.eagle.internal.province.Province as ProvinceProto
|
||||
import net.eagle0.eagle.library.util.faction_utils.{FactionUtils, LegacyFactionUtils}
|
||||
import net.eagle0.eagle.library.util.hero.{HeroUtils, LegacyHeroUtils}
|
||||
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
|
||||
import net.eagle0.eagle.library.util.hero.HeroUtils
|
||||
import net.eagle0.eagle.model.state.faction.FactionT
|
||||
import net.eagle0.eagle.model.state.hero.HeroT
|
||||
import net.eagle0.eagle.model.state.province.ProvinceT
|
||||
|
||||
object AIClientUtils {
|
||||
def extraHeroCount(pid: ProvinceId, fid: FactionId, gs: GameStateProto): Int =
|
||||
(gs.provinces(pid).rulingFactionHeroIds.size - desiredHeroCount(
|
||||
pid,
|
||||
def extraHeroCount(
|
||||
province: ProvinceT,
|
||||
fid: FactionId,
|
||||
allProvinces: Map[ProvinceId, ProvinceT],
|
||||
factions: Vector[FactionT]
|
||||
): Int =
|
||||
(province.rulingFactionHeroIds.size - desiredHeroCount(
|
||||
province,
|
||||
fid,
|
||||
gs
|
||||
allProvinces,
|
||||
factions
|
||||
)).max(0)
|
||||
|
||||
def desiredHeroCount(pid: ProvinceId, fid: FactionId, gs: GameStateProto): Int =
|
||||
if LegacyFactionUtils.hostileNeighbors(pid, fid, gs).nonEmpty then 3
|
||||
else if gs.provinces(pid).support < 65 then 2
|
||||
else 1
|
||||
|
||||
def desiredCountForMarchTowardFocus(
|
||||
originProvince: ProvinceProto,
|
||||
destinationProvince: ProvinceProto,
|
||||
availableHeroIds: Vector[HeroId],
|
||||
factionLeaders: Vector[HeroId],
|
||||
favorLeaders: Boolean
|
||||
def desiredHeroCount(
|
||||
province: ProvinceT,
|
||||
fid: FactionId,
|
||||
allProvinces: Map[ProvinceId, ProvinceT],
|
||||
factions: Vector[FactionT]
|
||||
): Int =
|
||||
if favorLeaders &&
|
||||
originProvince.rulingFactionHeroIds.size == 2 &&
|
||||
originProvince.rulingFactionHeroIds
|
||||
.intersect(factionLeaders)
|
||||
.nonEmpty &&
|
||||
destinationProvince.rulingFactionHeroIds
|
||||
.intersect(factionLeaders)
|
||||
.isEmpty
|
||||
then 1
|
||||
else
|
||||
Vector(
|
||||
10,
|
||||
originProvince.rulingFactionHeroIds.size - 2,
|
||||
availableHeroIds.size
|
||||
).min
|
||||
if FactionUtils.hostileNeighbors(province, fid, allProvinces, factions).nonEmpty then 3
|
||||
else if province.support < 65 then 2
|
||||
else 1
|
||||
|
||||
def desiredCountForMarchTowardFocus(
|
||||
originProvince: ProvinceT,
|
||||
@@ -68,26 +54,6 @@ object AIClientUtils {
|
||||
availableHeroIds.size
|
||||
).min
|
||||
|
||||
def takenHeroIdsForMarchTowardFocus(
|
||||
originProvince: ProvinceProto,
|
||||
destinationProvince: ProvinceProto,
|
||||
availableHeroIds: Vector[HeroId],
|
||||
favorLeaders: Boolean,
|
||||
gs: GameStateProto
|
||||
): Vector[HeroId] =
|
||||
mostPowerfulHeroes(
|
||||
desiredCountForMarchTowardFocus(
|
||||
originProvince = originProvince,
|
||||
destinationProvince = destinationProvince,
|
||||
availableHeroIds = availableHeroIds,
|
||||
factionLeaders = gs.factions(originProvince.rulingFactionId.get).leaders.toVector,
|
||||
favorLeaders = favorLeaders
|
||||
),
|
||||
gs,
|
||||
availableHeroIds,
|
||||
favorLeaders
|
||||
)
|
||||
|
||||
def takenHeroIdsForMarchTowardFocus(
|
||||
originProvince: ProvinceT,
|
||||
destinationProvince: ProvinceT,
|
||||
@@ -110,29 +76,6 @@ object AIClientUtils {
|
||||
heroes
|
||||
)
|
||||
|
||||
def mostPowerfulHeroes(
|
||||
desiredCount: Int,
|
||||
gs: GameStateProto,
|
||||
availableHeroIds: Vector[HeroId],
|
||||
favorLeaders: Boolean
|
||||
): Vector[HeroId] = {
|
||||
val availableHeroes = availableHeroIds
|
||||
.map(gs.heroes)
|
||||
|
||||
if availableHeroes.size <= desiredCount then availableHeroes.map(_.id)
|
||||
else
|
||||
availableHeroes
|
||||
.sortBy(hero =>
|
||||
(
|
||||
if favorLeaders then !LegacyFactionUtils.isFactionLeader(hero.id, gs)
|
||||
else LegacyFactionUtils.isFactionLeader(hero.id, gs),
|
||||
-LegacyHeroUtils.power(hero)
|
||||
)
|
||||
)
|
||||
.map(_.id)
|
||||
.take(desiredCount)
|
||||
}
|
||||
|
||||
def mostPowerfulHeroes(
|
||||
desiredCount: Int,
|
||||
availableHeroIds: Vector[HeroId],
|
||||
|
||||
@@ -15,7 +15,6 @@ scala_library(
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:eagle_scala_grpc",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
|
||||
"//src/main/scala/net/eagle0/common:functional_random",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library:engine",
|
||||
@@ -25,7 +24,6 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:alms_command_selector",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:attack_decision_command_chooser",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution",
|
||||
],
|
||||
)
|
||||
@@ -49,12 +47,9 @@ scala_library(
|
||||
"//src/test/scala/net/eagle0/eagle:__subpackages__",
|
||||
],
|
||||
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/faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/faction",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/hero",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/province",
|
||||
@@ -69,17 +64,19 @@ scala_library(
|
||||
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
|
||||
"//src/test/scala/net/eagle0/eagle/service:__pkg__",
|
||||
],
|
||||
exports = [
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
deps = [
|
||||
":resolve_diplomacy_command_selector",
|
||||
"//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/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:min_chance_for_a_i_invite",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
|
||||
"//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",
|
||||
],
|
||||
)
|
||||
@@ -91,15 +88,18 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle:__subpackages__",
|
||||
"//src/test/scala/net/eagle0/eagle/service:__pkg__",
|
||||
],
|
||||
exports = [
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
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/scala/net/eagle0/common:functional_random",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
|
||||
"//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",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/province",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -109,12 +109,16 @@ scala_library(
|
||||
visibility = [
|
||||
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
|
||||
],
|
||||
exports = [
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/province",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/province",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -124,15 +128,19 @@ scala_library(
|
||||
visibility = [
|
||||
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
|
||||
],
|
||||
exports = ["//src/main/scala/net/eagle0/eagle/library/util:command_selection"],
|
||||
exports = [
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
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:functional_random",
|
||||
"//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/command_choice_helpers:available_command_selector",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -167,6 +175,7 @@ scala_library(
|
||||
exports = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
deps = [
|
||||
":ai_client_utils",
|
||||
@@ -178,17 +187,14 @@ scala_library(
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:command_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
|
||||
"//src/main/scala/net/eagle0/common:functional_random",
|
||||
"//src/main/scala/net/eagle0/common:more_option",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:min_support_for_taxes",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:months_recon_considered_recent",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
|
||||
"//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",
|
||||
@@ -200,12 +206,17 @@ scala_library(
|
||||
"//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",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/food_consumption",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/province",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/date",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/faction",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/province",
|
||||
"//src/main/scala/net/eagle0/eagle/model/view/battalion",
|
||||
"//src/main/scala/net/eagle0/eagle/model/view/province:full_province_info",
|
||||
"//src/main/scala/net/eagle0/eagle/model/view/province:province_view",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -215,16 +226,17 @@ scala_library(
|
||||
visibility = [
|
||||
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
|
||||
],
|
||||
exports = [
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
deps = [
|
||||
":faction_leader_province_ranker",
|
||||
":march_toward_province_command_chooser",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:available_command_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/util:command_selection",
|
||||
"//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/model/proto_converters/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/faction",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
)
|
||||
@@ -235,10 +247,12 @@ scala_library(
|
||||
visibility = [
|
||||
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
|
||||
],
|
||||
exports = [
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
|
||||
"//src/main/scala/net/eagle0/common:functional_random",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
|
||||
@@ -251,8 +265,8 @@ scala_library(
|
||||
"//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/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/province",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -2,24 +2,23 @@ package net.eagle0.eagle.ai
|
||||
|
||||
import net.eagle0.common.{FunctionalRandom, RandomState}
|
||||
import net.eagle0.eagle.api.available_command.AvailableCommand
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChoiceHelpers.{
|
||||
chosenCommandWhileTraveling,
|
||||
chosenDevelopCommand,
|
||||
chosenUnderAttackCommand
|
||||
}
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooser
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.DeterministicCommandChooser
|
||||
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
|
||||
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
|
||||
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
|
||||
import net.eagle0.eagle.library.util.province.ProvinceUtils
|
||||
import net.eagle0.eagle.library.util.CommandSelection
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.FactionId
|
||||
|
||||
object EarlyGameAIClient {
|
||||
def isEarlyGame(gs: GameState, factionId: FactionId): Boolean =
|
||||
LegacyFactionUtils.provinceCount(factionId, gs) == 1 && !LegacyFactionUtils
|
||||
.provinces(factionId, gs)
|
||||
.exists(LegacyProvinceUtils.getsTaxes)
|
||||
FactionUtils.provinceCount(factionId, gs.provinces.values) == 1 && !FactionUtils
|
||||
.provinces(factionId, gs.provinces.values.toVector)
|
||||
.exists(ProvinceUtils.getsTaxes)
|
||||
|
||||
def chooseEarlyGameCommand(
|
||||
actingFactionId: FactionId,
|
||||
@@ -33,9 +32,12 @@ object EarlyGameAIClient {
|
||||
gameState = gs,
|
||||
availableCommands = acs,
|
||||
rankedChoosers = Vector[CommandChooser](
|
||||
chosenCommandWhileTraveling _,
|
||||
chosenUnderAttackCommand _,
|
||||
chosenDevelopCommand _
|
||||
(fid: FactionId, gs: GameState, acs: Vector[AvailableCommand], fr: FunctionalRandom) =>
|
||||
chosenCommandWhileTraveling(fid, gs, acs, fr),
|
||||
(fid: FactionId, gs: GameState, acs: Vector[AvailableCommand], fr: FunctionalRandom) =>
|
||||
RandomState(chosenUnderAttackCommand(fid, gs, acs), fr),
|
||||
(fid: FactionId, gs: GameState, acs: Vector[AvailableCommand], fr: FunctionalRandom) =>
|
||||
chosenDevelopCommand(fid, gs, acs, fr)
|
||||
),
|
||||
functionalRandom = functionalRandom
|
||||
)
|
||||
|
||||
@@ -1,59 +1,58 @@
|
||||
package net.eagle0.eagle.ai
|
||||
|
||||
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.province.LegacyProvinceUtils
|
||||
import net.eagle0.eagle.library.util.LegacyProvinceDistances
|
||||
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
|
||||
import net.eagle0.eagle.library.util.province.ProvinceUtils
|
||||
import net.eagle0.eagle.library.util.ProvinceDistances
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.model.state.province.ProvinceT
|
||||
|
||||
object FactionLeaderProvinceRanker {
|
||||
private def factionLeaderProvinceOrdering(
|
||||
factionId: FactionId,
|
||||
gameState: GameState,
|
||||
fromProvinceId: ProvinceId
|
||||
): Ordering[Province] = factionLeaderProvinceOrdering(factionId, gameState)
|
||||
): Ordering[ProvinceT] = factionLeaderProvinceOrdering(factionId, gameState)
|
||||
.orElse(
|
||||
nearestThroughFriendliesOrdering(factionId, gameState, fromProvinceId)
|
||||
)
|
||||
|
||||
// Orders by nearest-through-friendlies first
|
||||
private def nearestThroughFriendliesOrdering(
|
||||
factionId: FactionId,
|
||||
gameState: GameState,
|
||||
fromProvinceId: ProvinceId
|
||||
): Ordering[Province] = Ordering.by((p: Province) =>
|
||||
LegacyProvinceDistances.distanceThroughFriendliesOption(
|
||||
): Ordering[ProvinceT] = Ordering.by((p: ProvinceT) =>
|
||||
ProvinceDistances.distanceThroughFriendliesOption(
|
||||
fromProvinceId,
|
||||
p.id,
|
||||
factionId,
|
||||
gameState
|
||||
gameState.provinces
|
||||
)
|
||||
)
|
||||
|
||||
// Orders by most-hostile-neighbors-first
|
||||
private def hostileNeighborCountOrdering(
|
||||
factionId: FactionId,
|
||||
gameState: GameState
|
||||
): Ordering[Province] = Ordering
|
||||
.by((p: Province) => LegacyFactionUtils.hostileNeighbors(p, factionId, gameState).size)
|
||||
): Ordering[ProvinceT] = Ordering
|
||||
.by((p: ProvinceT) =>
|
||||
FactionUtils.hostileNeighbors(p, factionId, gameState.provinces, gameState.factions.values.toVector).size
|
||||
)
|
||||
.reverse
|
||||
|
||||
// Orders by most-neutral-neighbors-first
|
||||
private def neutralNeighborCountOrdering(
|
||||
gameState: GameState
|
||||
): Ordering[Province] =
|
||||
Ordering.by((p: Province) => neutralNeighborCount(p, gameState)).reverse
|
||||
): Ordering[ProvinceT] =
|
||||
Ordering.by((p: ProvinceT) => neutralNeighborCount(p, gameState)).reverse
|
||||
|
||||
private def factionLeaderProvinceOrdering(
|
||||
factionId: FactionId,
|
||||
gameState: GameState
|
||||
): Ordering[Province] =
|
||||
): Ordering[ProvinceT] =
|
||||
hostileNeighborCountOrdering(factionId = factionId, gameState = gameState)
|
||||
.orElse(neutralNeighborCountOrdering(gameState))
|
||||
|
||||
private def neutralNeighborCount(
|
||||
province: Province,
|
||||
province: ProvinceT,
|
||||
gameState: GameState
|
||||
): Int =
|
||||
province.neighbors
|
||||
@@ -62,7 +61,6 @@ object FactionLeaderProvinceRanker {
|
||||
.filterNot(_.rulingFactionId.isDefined)
|
||||
.size
|
||||
|
||||
// Sorts the owned provinces for this faction, with the most desirable for faction leaders coming first
|
||||
def rankedProvinceIds(
|
||||
factionId: FactionId,
|
||||
gameState: GameState
|
||||
@@ -71,8 +69,8 @@ object FactionLeaderProvinceRanker {
|
||||
factionId = factionId,
|
||||
gameState = gameState
|
||||
)
|
||||
LegacyFactionUtils
|
||||
.provinces(factionId, gameState)
|
||||
FactionUtils
|
||||
.provinces(factionId, gameState.provinces.values.toVector)
|
||||
.sorted(ordering)
|
||||
.map(_.id)
|
||||
}
|
||||
@@ -85,8 +83,8 @@ object FactionLeaderProvinceRanker {
|
||||
factionId = factionId,
|
||||
gameState = gameState
|
||||
)
|
||||
LegacyFactionUtils
|
||||
.provinces(factionId, gameState)
|
||||
FactionUtils
|
||||
.provinces(factionId, gameState.provinces.values.toVector)
|
||||
.minOption(ordering)
|
||||
.map(_.id)
|
||||
}
|
||||
@@ -101,8 +99,8 @@ object FactionLeaderProvinceRanker {
|
||||
gameState = gameState,
|
||||
fromProvinceId = fromProvinceId
|
||||
)
|
||||
LegacyFactionUtils
|
||||
.provinces(factionId, gameState)
|
||||
FactionUtils
|
||||
.provinces(factionId, gameState.provinces.values.toVector)
|
||||
.minOption(ordering)
|
||||
.map(_.id)
|
||||
}
|
||||
@@ -117,27 +115,28 @@ object FactionLeaderProvinceRanker {
|
||||
gameState = gameState,
|
||||
fromProvinceId = fromProvinceId
|
||||
)
|
||||
LegacyFactionUtils
|
||||
.provinces(factionId, gameState)
|
||||
FactionUtils
|
||||
.provinces(factionId, gameState.provinces.values.toVector)
|
||||
.sorted(ordering)
|
||||
.map(_.id)
|
||||
}
|
||||
|
||||
def bestSettlementPidForFactionLeader(
|
||||
factionId: FactionId,
|
||||
currentProvince: Province,
|
||||
currentProvince: ProvinceT,
|
||||
gameState: GameState
|
||||
): Option[ProvinceId] = {
|
||||
val factions = gameState.factions.values.toVector
|
||||
val ordering = factionLeaderProvinceOrdering(
|
||||
factionId = factionId,
|
||||
gameState = gameState,
|
||||
fromProvinceId = currentProvince.id
|
||||
)
|
||||
|
||||
LegacyFactionUtils
|
||||
.provinces(factionId, gameState)
|
||||
FactionUtils
|
||||
.provinces(factionId, gameState.provinces.values.toVector)
|
||||
.filter(ordering.lt(_, currentProvince))
|
||||
.filterNot(LegacyProvinceUtils.ruledByFactionLeader(_, gameState))
|
||||
.filterNot(ProvinceUtils.ruledByFactionLeader(_, factions))
|
||||
.minOption(ordering)
|
||||
.map(_.id)
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import net.eagle0.common.{FunctionalRandom, RandomState}
|
||||
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.library.util.command_choice_helpers.AvailableCommandSelector.randomSelectionForType
|
||||
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
|
||||
import net.eagle0.eagle.FactionId
|
||||
|
||||
object FixLeaderAloneCommandSelector {
|
||||
@@ -16,7 +16,9 @@ object FixLeaderAloneCommandSelector {
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] =
|
||||
): RandomState[Option[CommandSelection]] = {
|
||||
val factions = gameState.factions.values.toVector
|
||||
val provinces = gameState.provinces
|
||||
randomSelectionForType[MarchAvailableCommand](
|
||||
availableCommands,
|
||||
functionalRandom
|
||||
@@ -25,21 +27,16 @@ object FixLeaderAloneCommandSelector {
|
||||
frOuter.nextFlatCollectFirst(
|
||||
ac.oneProvinceCommands
|
||||
.filter(opmc =>
|
||||
gameState
|
||||
.provinces(opmc.originProvinceId)
|
||||
.rulingFactionHeroIds
|
||||
.filterNot(LegacyFactionUtils.isFactionLeader(_, gameState))
|
||||
provinces(opmc.originProvinceId).rulingFactionHeroIds
|
||||
.filterNot(FactionUtils.isFactionLeader(_, factions))
|
||||
.size > 1
|
||||
)
|
||||
) { opmc => fr =>
|
||||
opmc.availableDestinationProvinces
|
||||
.map(dest => gameState.provinces(dest.provinceId))
|
||||
.map(dest => provinces(dest.provinceId))
|
||||
.filter(_.rulingFactionId.contains(actingFactionId))
|
||||
.filter(_.rulingFactionHeroIds.size == 1)
|
||||
.filter(dest =>
|
||||
LegacyFactionUtils
|
||||
.isFactionLeader(dest.rulingFactionHeroIds.head, gameState)
|
||||
)
|
||||
.filter(dest => FactionUtils.isFactionLeader(dest.rulingFactionHeroIds.head, factions))
|
||||
.map { destination =>
|
||||
fr.nextRandomElement(opmc.availableHeroIds)
|
||||
.map { hid =>
|
||||
@@ -69,4 +66,5 @@ object FixLeaderAloneCommandSelector {
|
||||
.getOrElse(RandomState(None, fr))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,11 @@ package net.eagle0.eagle.ai
|
||||
import net.eagle0.eagle.api.available_command.{AvailableCommand, DiplomacyAvailableCommand}
|
||||
import net.eagle0.eagle.api.command.util.diplomacy_option.InvitationOption
|
||||
import net.eagle0.eagle.api.selected_command.DiplomacySelectedCommand
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
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.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.FactionId
|
||||
|
||||
object InvitationCommandSelector {
|
||||
@@ -23,16 +22,15 @@ object InvitationCommandSelector {
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
flatSelectionForType[DiplomacyAvailableCommand](acs = availableCommands) { diplomacyAvailableCommand =>
|
||||
val nativeGameState = GameStateConverter.fromProto(gameState)
|
||||
diplomacyAvailableCommand.options.collect { case io: InvitationOption => io }.filter { invitationOption =>
|
||||
TrustForDiplomacy.meetsConditionsForInvitation(
|
||||
actingFactionId = actingFactionId,
|
||||
targetFactionId = invitationOption.targetFactionId,
|
||||
gameState = nativeGameState
|
||||
gameState = gameState
|
||||
)
|
||||
}.filter { invitationOption =>
|
||||
ProvinceGoldSurplusCalculator.provinceGoldSurplus(
|
||||
nativeGameState.provinces(diplomacyAvailableCommand.actingProvinceId)
|
||||
gameState.provinces(diplomacyAvailableCommand.actingProvinceId)
|
||||
) >= invitationOption.goldCost
|
||||
}.map { invitationOption =>
|
||||
InvitationOptionWithChance(
|
||||
@@ -40,8 +38,8 @@ object InvitationCommandSelector {
|
||||
acceptanceChance = ResolveDiplomacyCommandSelector.invitationAcceptanceChance(
|
||||
actingFactionId,
|
||||
invitationOption.targetFactionId,
|
||||
nativeGameState.factions.values.toVector,
|
||||
nativeGameState.provinces.values.toVector
|
||||
gameState.factions.values.toVector,
|
||||
gameState.provinces.values.toVector
|
||||
)
|
||||
)
|
||||
}.maxByOption(_.acceptanceChance)
|
||||
|
||||
@@ -2,15 +2,12 @@ package net.eagle0.eagle.ai
|
||||
|
||||
import net.eagle0.common.{FunctionalRandom, MoreOption, RandomState}
|
||||
import net.eagle0.eagle.{FactionId, ProvinceId}
|
||||
import net.eagle0.eagle.ai.AIClientUtils.extraHeroCount
|
||||
import net.eagle0.eagle.ai.AIClientUtils
|
||||
import net.eagle0.eagle.api.available_command.*
|
||||
import net.eagle0.eagle.api.command.OneProvinceAvailableCommands
|
||||
import net.eagle0.eagle.api.selected_command.{MarchSelectedCommand, ReconSelectedCommand}
|
||||
import net.eagle0.eagle.common.combat_unit.CombatUnit
|
||||
import net.eagle0.eagle.common.improvement_type.ImprovementType
|
||||
import net.eagle0.eagle.internal.faction_relationship.FactionRelationship
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.internal.province.Province
|
||||
import net.eagle0.eagle.library.settings.{MinSupportForTaxes, MonthsReconConsideredRecent}
|
||||
import net.eagle0.eagle.library.util.*
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.{
|
||||
@@ -27,27 +24,29 @@ import net.eagle0.eagle.library.util.command_choice_helpers.{
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.flatSelectionForType
|
||||
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.faction_utils.FactionUtils
|
||||
import net.eagle0.eagle.library.util.food_consumption.FoodConsumptionUtils
|
||||
import net.eagle0.eagle.library.util.hero.HeroUtils
|
||||
import net.eagle0.eagle.library.util.province.ProvinceUtils
|
||||
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.model.proto_converters.province.ProvinceConverter
|
||||
import net.eagle0.eagle.views.battalion_view.BattalionView
|
||||
import net.eagle0.eagle.views.province_view.FullProvinceInfo
|
||||
import net.eagle0.eagle.library.util.ProvinceDistances
|
||||
import net.eagle0.eagle.model.state.date.Date.given
|
||||
import net.eagle0.eagle.model.state.faction.FactionRelationship as FactionRelationshipC
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.model.state.province.ProvinceT
|
||||
import net.eagle0.eagle.model.view.battalion.BattalionView as BattalionViewC
|
||||
import net.eagle0.eagle.model.view.province.FullProvinceInfo as FullProvinceInfoC
|
||||
|
||||
object MidGameAIClient {
|
||||
private def validateNoFactionLeaderAlone(
|
||||
gs: GameState,
|
||||
factionId: FactionId
|
||||
): Unit =
|
||||
LegacyFactionUtils
|
||||
.provinces(factionId, gs)
|
||||
FactionUtils
|
||||
.provinces(factionId, gs.provinces.values.toVector)
|
||||
.filter { province =>
|
||||
province.rulingFactionHeroIds.forall(
|
||||
LegacyFactionUtils.isFactionLeader(_, gs)
|
||||
FactionUtils.isFactionLeader(_, gs.factions.values.toVector)
|
||||
)
|
||||
}
|
||||
.foreach { province =>
|
||||
@@ -69,7 +68,8 @@ object MidGameAIClient {
|
||||
gameState = gs,
|
||||
availableCommands = opac.commands.toVector,
|
||||
rankedChoosers = Vector[CommandChooser](
|
||||
chosenUniversalCommand,
|
||||
(fid: FactionId, gs: GameState, acs: Vector[AvailableCommand], fr: FunctionalRandom) =>
|
||||
chosenUniversalCommand(fid, gs, acs, fr),
|
||||
chosenRescueLeaderCommand,
|
||||
(actingFactionId, gameState, availableCommands, functionalRandom) =>
|
||||
RandomState(
|
||||
@@ -140,44 +140,41 @@ object MidGameAIClient {
|
||||
) =>
|
||||
val actingProvince = gameState.provinces(actingProvinceId)
|
||||
val foodMonthsToHoldBack =
|
||||
LegacyFoodConsumptionUtils.foodConsumptionMonthsToHold(
|
||||
actingProvinceId,
|
||||
FoodConsumptionUtils.foodConsumptionMonthsToHold(
|
||||
actingProvince,
|
||||
gameState
|
||||
)
|
||||
val foodToHoldBack =
|
||||
foodMonthsToHoldBack * LegacyProvinceUtils.monthlyFoodConsumption(
|
||||
actingProvinceId,
|
||||
foodMonthsToHoldBack * ProvinceUtils.monthlyFoodConsumption(
|
||||
actingProvince,
|
||||
gameState
|
||||
)
|
||||
val maxFoodToSend =
|
||||
Math.min(actingProvince.food - foodToHoldBack, foodAvailable)
|
||||
val maxGoldToSend = Math.min(
|
||||
ProvinceGoldSurplusCalculator
|
||||
.provinceGoldSurplus(ProvinceConverter.fromProto(actingProvince)),
|
||||
ProvinceGoldSurplusCalculator.provinceGoldSurplus(actingProvince),
|
||||
goldAvailable
|
||||
)
|
||||
|
||||
MoreOption.flatWhen(maxFoodToSend > 0) {
|
||||
case class DestinationFoodStatus(
|
||||
province: Province,
|
||||
case class DestinationFoodStatusC(
|
||||
province: ProvinceT,
|
||||
hasSupport: Boolean,
|
||||
monthsOfFood: Int
|
||||
)
|
||||
|
||||
// If any province is running low on food, choose the one with the fewest months remaining and send some
|
||||
val bestDestination = availableDestinationProvinceIds
|
||||
.map(gameState.provinces)
|
||||
.filter(_.rulingFactionId.contains(factionId))
|
||||
.map { province =>
|
||||
val consumption = LegacyProvinceUtils
|
||||
.monthlyFoodConsumption(province.id, gameState)
|
||||
val consumption = ProvinceUtils.monthlyFoodConsumption(province, gameState)
|
||||
val incomingFood =
|
||||
province.incomingShipments.flatMap(_.supplies).map(_.food).sum
|
||||
province.incomingShipments.map(_.supplies.food).sum
|
||||
val availableFood = province.food + incomingFood
|
||||
val monthsOfFood =
|
||||
if consumption == 0 then 120
|
||||
else availableFood / consumption
|
||||
DestinationFoodStatus(
|
||||
DestinationFoodStatusC(
|
||||
province = province,
|
||||
hasSupport = province.support >= MinSupportForTaxes.doubleValue,
|
||||
monthsOfFood = monthsOfFood
|
||||
@@ -188,8 +185,8 @@ object MidGameAIClient {
|
||||
|
||||
bestDestination.map { destinationFoodStatus =>
|
||||
val destinationProvince = destinationFoodStatus.province
|
||||
val desiredFood = LegacyProvinceUtils.monthlyFoodConsumption(
|
||||
destinationProvince.id,
|
||||
val desiredFood = ProvinceUtils.monthlyFoodConsumption(
|
||||
destinationProvince,
|
||||
gameState
|
||||
) * 6 - destinationProvince.food
|
||||
val foodToSend =
|
||||
@@ -272,7 +269,7 @@ object MidGameAIClient {
|
||||
RandomState(
|
||||
ImproveCommandSelector.chosenImproveCommand(
|
||||
actingFactionId,
|
||||
GameStateConverter.fromProto(gameState),
|
||||
gameState,
|
||||
availableCommands
|
||||
),
|
||||
functionalRandom
|
||||
@@ -301,7 +298,7 @@ object MidGameAIClient {
|
||||
RandomState(
|
||||
ImproveCommandSelector.chosenImproveCommand(
|
||||
actingFactionId,
|
||||
GameStateConverter.fromProto(gameState),
|
||||
gameState,
|
||||
availableCommands
|
||||
),
|
||||
functionalRandom
|
||||
@@ -346,20 +343,9 @@ object MidGameAIClient {
|
||||
actingFactionId = actingFactionId,
|
||||
gs = gameState,
|
||||
acs = availableCommands,
|
||||
choosersAndReasons = Vector(
|
||||
choosersAndReasons = Vector[(CommandChooser, String)](
|
||||
(
|
||||
(
|
||||
fid: FactionId,
|
||||
gs: GameState,
|
||||
acs: Vector[AvailableCommand],
|
||||
fr: FunctionalRandom
|
||||
) =>
|
||||
ExpandCommandSelector.maybeChosenExpandCommand(
|
||||
fid,
|
||||
GameStateConverter.fromProto(gs),
|
||||
acs,
|
||||
fr
|
||||
),
|
||||
ExpandCommandSelector.maybeChosenExpandCommand,
|
||||
"no hostile neighbors, expand"
|
||||
),
|
||||
(
|
||||
@@ -376,7 +362,7 @@ object MidGameAIClient {
|
||||
RandomState(
|
||||
ImproveCommandSelector.chosenImproveCommand(
|
||||
actingFactionId,
|
||||
GameStateConverter.fromProto(gameState),
|
||||
gameState,
|
||||
availableCommands
|
||||
),
|
||||
functionalRandom
|
||||
@@ -414,8 +400,7 @@ object MidGameAIClient {
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] = {
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.DeterministicCommandChooser
|
||||
): RandomState[Option[CommandSelection]] =
|
||||
provincesWithFactionLeader(actingFactionId, gameState).map { province =>
|
||||
MoreOption
|
||||
.flatWhen(province.rulingFactionHeroIds.size < 4)(
|
||||
@@ -431,7 +416,7 @@ object MidGameAIClient {
|
||||
actingFactionId = actingFactionId,
|
||||
gs = gameState,
|
||||
acs = availableCommands,
|
||||
choosersAndReasons = Vector(
|
||||
choosersAndReasons = Vector[(CommandChooser, String)](
|
||||
(
|
||||
maybeMoveToRecruitCommand,
|
||||
"chosen no neutral neighbors, travel to recruit"
|
||||
@@ -464,7 +449,7 @@ object MidGameAIClient {
|
||||
RandomState(
|
||||
ImproveCommandSelector.chosenImproveCommand(
|
||||
actingFactionId,
|
||||
GameStateConverter.fromProto(gameState),
|
||||
gameState,
|
||||
availableCommands
|
||||
),
|
||||
functionalRandom
|
||||
@@ -496,30 +481,29 @@ object MidGameAIClient {
|
||||
}
|
||||
.find(_.newValue.isDefined)
|
||||
.get
|
||||
}
|
||||
|
||||
private def chosenAttackCommandWithReconInfo(
|
||||
actingFactionId: FactionId,
|
||||
gs: GameState,
|
||||
acs: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] = {
|
||||
def reconnedFullInfoFor(provinceId: ProvinceId): Option[FullProvinceInfo] =
|
||||
def reconnedFullInfoFor(provinceId: ProvinceId): Option[FullProvinceInfoC] =
|
||||
gs.factions(actingFactionId)
|
||||
.reconnedProvinces
|
||||
.find(_.id == provinceId)
|
||||
.flatMap(_.fullInfo)
|
||||
def reconnedHeroCountFor(provinceId: ProvinceId): Int =
|
||||
def reconnedHeroCountFor(provinceId: ProvinceId): Int =
|
||||
reconnedFullInfoFor(provinceId)
|
||||
.map(_.rulingFactionHeroIds.size)
|
||||
.getOrElse(0)
|
||||
def reconnedBattalionsFor(provinceId: ProvinceId): Vector[BattalionView] =
|
||||
def reconnedBattalionsFor(provinceId: ProvinceId): Vector[BattalionViewC] =
|
||||
reconnedFullInfoFor(provinceId)
|
||||
.map(_.battalions.toVector)
|
||||
.map(_.battalions)
|
||||
.getOrElse(Vector())
|
||||
|
||||
AttackCommandChooser.chosenAttackCommand(
|
||||
actingFactionId,
|
||||
GameStateConverter.fromProto(gs),
|
||||
gs,
|
||||
acs,
|
||||
reconnedHeroCountFor,
|
||||
reconnedBattalionsFor
|
||||
@@ -539,13 +523,13 @@ object MidGameAIClient {
|
||||
AlmsCommandSelector
|
||||
.chosenAlmsForSupportCommand(
|
||||
actingFactionId = actingFactionId,
|
||||
gameState = GameStateConverter.fromProto(gameState),
|
||||
gameState = gameState,
|
||||
availableCommands = availableCommands
|
||||
)
|
||||
.orElse {
|
||||
ImproveCommandSelector.chosenImproveCommand(
|
||||
actingFactionId = actingFactionId,
|
||||
gameState = GameStateConverter.fromProto(gameState),
|
||||
gameState = gameState,
|
||||
availableCommands = availableCommands
|
||||
)
|
||||
}
|
||||
@@ -578,7 +562,7 @@ object MidGameAIClient {
|
||||
RandomState(
|
||||
AttackCommandChooser.chosenSupportAttackCommand(
|
||||
actingFactionId,
|
||||
GameStateConverter.fromProto(gameState),
|
||||
gameState,
|
||||
availableCommands
|
||||
),
|
||||
functionalRandom
|
||||
@@ -628,7 +612,7 @@ object MidGameAIClient {
|
||||
RandomState(
|
||||
SeekMoreLeadersCommandChooser.maybeSeekMoreLeadersCommand(
|
||||
actingFactionId,
|
||||
GameStateConverter.fromProto(gameState),
|
||||
gameState,
|
||||
availableCommands
|
||||
),
|
||||
functionalRandom
|
||||
@@ -648,11 +632,12 @@ object MidGameAIClient {
|
||||
maybeCommand.filter { _ =>
|
||||
provincesWithFactionLeader(fid, gs).exists { p =>
|
||||
p.id == opac.provinceId &&
|
||||
LegacyFactionUtils
|
||||
FactionUtils
|
||||
.hostileNeighbors(
|
||||
province = p,
|
||||
fid = fid,
|
||||
gs = gs
|
||||
allProvinces = gs.provinces,
|
||||
factions = gs.factions.values.toVector
|
||||
)
|
||||
.nonEmpty
|
||||
}
|
||||
@@ -668,11 +653,10 @@ object MidGameAIClient {
|
||||
maybeCommand.filter { _ =>
|
||||
provincesWithFactionLeader(fid, gs).exists { p =>
|
||||
p.id == opac.provinceId &&
|
||||
LegacyFactionUtils
|
||||
FactionUtils
|
||||
.neutralNeighbors(
|
||||
province = p,
|
||||
fid = fid,
|
||||
gs = gs
|
||||
allProvinces = gs.provinces
|
||||
)
|
||||
.nonEmpty
|
||||
}
|
||||
@@ -690,7 +674,7 @@ object MidGameAIClient {
|
||||
): Int =
|
||||
gs.provinces.values
|
||||
.filter(_.rulingFactionId.contains(factionId))
|
||||
.map(p => LegacyProvinceUtils.monthlyFoodSurplus(gs, p.id))
|
||||
.map(p => ProvinceUtils.monthlyFoodSurplus(p, gs))
|
||||
.sum
|
||||
.max(0) / 2 // making this up
|
||||
|
||||
@@ -713,7 +697,7 @@ object MidGameAIClient {
|
||||
// we are under one of the required stats for better units
|
||||
MoreOption.flatWhen(
|
||||
available
|
||||
.forall(bt => existingBattalions.exists(_.`type` == bt.typeId)) &&
|
||||
.forall(bt => existingBattalions.exists(_.typeId == bt.typeId)) &&
|
||||
existingBattalions.size < province.rulingFactionHeroIds.size &&
|
||||
notAvailable.nonEmpty
|
||||
) {
|
||||
@@ -767,7 +751,7 @@ object MidGameAIClient {
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] = {
|
||||
def go(
|
||||
provincesRS: RandomState[Vector[Province]]
|
||||
provincesRS: RandomState[Vector[ProvinceT]]
|
||||
): RandomState[Option[CommandSelection]] = provincesRS.continue {
|
||||
case (province +: tail, fr) =>
|
||||
MoreOption
|
||||
@@ -794,22 +778,14 @@ object MidGameAIClient {
|
||||
"improve to hire better troops in preparation for attack"
|
||||
),
|
||||
(
|
||||
(
|
||||
fid: FactionId,
|
||||
gs: GameState,
|
||||
acs: Vector[AvailableCommand],
|
||||
fr: FunctionalRandom
|
||||
) =>
|
||||
(fid, gs, acs, fr) =>
|
||||
RandomState(
|
||||
OrganizeCommandSelector
|
||||
.chosenOrganizeCommandWithFoodSurplus(
|
||||
actingFactionId = fid,
|
||||
gameState = GameStateConverter.fromProto(gs),
|
||||
gameState = gs,
|
||||
availableCommands = acs,
|
||||
extraFoodSurplus = foodSurplus(
|
||||
gs,
|
||||
actingFactionId
|
||||
),
|
||||
extraFoodSurplus = foodSurplus(gs, actingFactionId),
|
||||
reason = "organize in preparation for attack"
|
||||
),
|
||||
fr
|
||||
@@ -817,12 +793,7 @@ object MidGameAIClient {
|
||||
"organize in preparation for attack"
|
||||
),
|
||||
(
|
||||
(
|
||||
actingFactionId,
|
||||
gameState,
|
||||
availableCommands,
|
||||
functionalRandom
|
||||
) =>
|
||||
(actingFactionId, gameState, availableCommands, functionalRandom) =>
|
||||
RandomState(
|
||||
maybeChosenTravelToArmTroopsCommand(
|
||||
actingFactionId,
|
||||
@@ -834,12 +805,7 @@ object MidGameAIClient {
|
||||
"travel to arm troops in preparation for attack"
|
||||
),
|
||||
(
|
||||
(
|
||||
actingFactionId,
|
||||
gameState,
|
||||
availableCommands,
|
||||
functionalRandom
|
||||
) =>
|
||||
(actingFactionId, gameState, availableCommands, functionalRandom) =>
|
||||
RandomState(
|
||||
chosenImproveInfrastructureIfBelowTrainingCommand(
|
||||
actingFactionId,
|
||||
@@ -851,12 +817,7 @@ object MidGameAIClient {
|
||||
"improve infrastructure in preparation for attack"
|
||||
),
|
||||
(
|
||||
(
|
||||
actingFactionId,
|
||||
gameState,
|
||||
availableCommands,
|
||||
functionalRandom
|
||||
) =>
|
||||
(actingFactionId, gameState, availableCommands, functionalRandom) =>
|
||||
RandomState(
|
||||
chosenTrainCommand(
|
||||
actingFactionId,
|
||||
@@ -894,24 +855,29 @@ object MidGameAIClient {
|
||||
provincesWithFactionLeader(actingFactionId, gs)
|
||||
.filter(p => p.rulingFactionHeroIds.size < p.heroCap)
|
||||
val cs = for {
|
||||
(marchCommand, idx) <- acs.zipWithIndex.collectFirst {
|
||||
case (ac: MarchAvailableCommand, idx) => (ac, idx)
|
||||
}
|
||||
(marchCommand, _) <- acs.zipWithIndex.collectFirst {
|
||||
case (ac: MarchAvailableCommand, idx) => (ac, idx)
|
||||
}
|
||||
} yield {
|
||||
// Find the march command origin provinces that contain extra heroes
|
||||
val candidateCommands = marchCommand.oneProvinceCommands.filter { opmc =>
|
||||
extraHeroCount(opmc.originProvinceId, actingFactionId, gs) > 0
|
||||
AIClientUtils.extraHeroCount(
|
||||
gs.provinces(opmc.originProvinceId),
|
||||
actingFactionId,
|
||||
gs.provinces,
|
||||
gs.factions.values.toVector
|
||||
) > 0
|
||||
}
|
||||
|
||||
val furthestOrigin = candidateCommands.flatMap { opmc =>
|
||||
// distance to the faction leader province we're closest to
|
||||
factionLeaderProvincesNeedingHeroes.flatMap { flp =>
|
||||
LegacyProvinceDistances
|
||||
ProvinceDistances
|
||||
.distanceThroughFriendliesOption(
|
||||
p1 = flp.id,
|
||||
p2 = opmc.originProvinceId,
|
||||
fid = actingFactionId,
|
||||
gs = gs
|
||||
provinces = gs.provinces
|
||||
)
|
||||
.map {
|
||||
(_: Int, flp.id: ProvinceId)
|
||||
@@ -925,7 +891,12 @@ object MidGameAIClient {
|
||||
// distance to the faction leader province we're closest to
|
||||
distance,
|
||||
destinationProvinceId,
|
||||
extraHeroCount(opmc.originProvinceId, actingFactionId, gs),
|
||||
AIClientUtils.extraHeroCount(
|
||||
gs.provinces(opmc.originProvinceId),
|
||||
actingFactionId,
|
||||
gs.provinces,
|
||||
gs.factions.values.toVector
|
||||
),
|
||||
opmc
|
||||
)
|
||||
}
|
||||
@@ -941,7 +912,7 @@ object MidGameAIClient {
|
||||
fid = actingFactionId,
|
||||
opmc = opmc,
|
||||
destinationProvinceId = destinationProvinceId,
|
||||
gs = GameStateConverter.fromProto(gs),
|
||||
gs = gs,
|
||||
favorLeaders = false
|
||||
)
|
||||
}
|
||||
@@ -971,9 +942,7 @@ object MidGameAIClient {
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
acs: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] = {
|
||||
import DateProtoUtils._Date
|
||||
|
||||
): Option[CommandSelection] =
|
||||
flatSelectionForType[ReconAvailableCommand](acs) { ac =>
|
||||
val targetProvincesNeighboringMe = ac.availableTargetProvinces
|
||||
.map(gameState.provinces)
|
||||
@@ -1005,13 +974,7 @@ object MidGameAIClient {
|
||||
.find(_.id == province.id)
|
||||
)
|
||||
.filter(
|
||||
_.asOf.exists(date =>
|
||||
DateProtoUtils
|
||||
.addMonths(
|
||||
date,
|
||||
MonthsReconConsideredRecent.intValue
|
||||
) < gameState.currentDate.get
|
||||
)
|
||||
_.asOf.exists(date => date.addMonths(MonthsReconConsideredRecent.intValue) < gameState.currentDate.get)
|
||||
)
|
||||
.sortBy(province =>
|
||||
(
|
||||
@@ -1024,7 +987,7 @@ object MidGameAIClient {
|
||||
.provinces(province.id)
|
||||
.rulingFactionId
|
||||
.get
|
||||
&& fr.relationshipLevel != FactionRelationship.RelationshipLevel.HOSTILE
|
||||
&& fr.relationshipLevel != FactionRelationshipC.RelationshipLevel.Hostile
|
||||
),
|
||||
// then the least recently scouted
|
||||
province.asOf.get
|
||||
@@ -1041,8 +1004,7 @@ object MidGameAIClient {
|
||||
targetProvinceId = pid
|
||||
)
|
||||
)
|
||||
}
|
||||
}.map(_.withReason("recon a neighbor"))
|
||||
}.map(_.withReason("recon a neighbor"))
|
||||
|
||||
def maybeSpreadLeadersCommand(
|
||||
actingFactionId: FactionId,
|
||||
@@ -1050,7 +1012,7 @@ object MidGameAIClient {
|
||||
acs: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] = {
|
||||
val faction = gameState.factions(actingFactionId)
|
||||
val factionLeaders = faction.leaders
|
||||
val factionLeaders = faction.leaderIds
|
||||
flatSelectionForType[MarchAvailableCommand](acs) { marchAvailableCommand =>
|
||||
marchAvailableCommand.oneProvinceCommands.flatMap { opmc =>
|
||||
val originProvince = gameState.provinces(opmc.originProvinceId)
|
||||
@@ -1064,7 +1026,7 @@ object MidGameAIClient {
|
||||
val leaderIdToMove =
|
||||
opmc.availableHeroIds
|
||||
.intersect(factionLeaders)
|
||||
.maxBy(hid => LegacyHeroUtils.seniorityOrder(faction, hid))
|
||||
.maxBy(hid => HeroUtils.seniorityOrder(faction, hid))
|
||||
val validDestinations = opmc.availableDestinationProvinces
|
||||
.map(dest => gameState.provinces(dest.provinceId))
|
||||
.filter(
|
||||
@@ -1106,7 +1068,7 @@ object MidGameAIClient {
|
||||
private def provincesWithFactionLeader(
|
||||
factionId: FactionId,
|
||||
gs: GameState
|
||||
): Vector[Province] = LegacyFactionUtils
|
||||
.provinces(factionId, gs)
|
||||
.filter(p => LegacyProvinceUtils.ruledByFactionLeader(p, gs))
|
||||
): Vector[ProvinceT] = FactionUtils
|
||||
.provinces(factionId, gs.provinces.values.toVector)
|
||||
.filter(p => ProvinceUtils.ruledByFactionLeader(p, gs.factions.values.toVector))
|
||||
}
|
||||
|
||||
@@ -2,12 +2,11 @@ package net.eagle0.eagle.ai
|
||||
|
||||
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
|
||||
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
|
||||
import net.eagle0.eagle.model.state.faction.FactionT
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
|
||||
object MoveLeaderToBetterProvinceCommandChooser {
|
||||
private case class MCFOPWithDestinationAndDistance(
|
||||
@@ -27,17 +26,18 @@ object MoveLeaderToBetterProvinceCommandChooser {
|
||||
actingFactionId = actingFactionId,
|
||||
gs = gs,
|
||||
acs = acs,
|
||||
betterDestinationChooser = FactionLeaderProvinceRanker.bestSettlementPidForFactionLeader
|
||||
betterDestinationChooser = (fid: FactionId, pid: ProvinceId, gameState: GameState) =>
|
||||
FactionLeaderProvinceRanker.bestSettlementPidForFactionLeader(fid, pid, gameState)
|
||||
)
|
||||
|
||||
private def candidatesForMarchCommand(
|
||||
marchCommand: MarchAvailableCommand,
|
||||
gs: GameState,
|
||||
faction: Faction
|
||||
faction: FactionT
|
||||
): Vector[MarchCommandFromOneProvince] =
|
||||
marchCommand.oneProvinceCommands.toVector.filter { opmc =>
|
||||
val originProvince = gs.provinces(opmc.originProvinceId)
|
||||
if opmc.availableHeroIds.intersect(faction.leaders).isEmpty then false
|
||||
if opmc.availableHeroIds.intersect(faction.leaderIds).isEmpty then false
|
||||
else if originProvince.rulingFactionHeroIds.length < 2 then false
|
||||
else true
|
||||
}
|
||||
@@ -54,12 +54,12 @@ object MoveLeaderToBetterProvinceCommandChooser {
|
||||
mcfop.originProvinceId,
|
||||
gs
|
||||
).flatMap { desiredPid =>
|
||||
LegacyProvinceDistances
|
||||
ProvinceDistances
|
||||
.closestProvinceThroughFriendliesOption(
|
||||
desiredPid,
|
||||
mcfop.availableDestinationProvinces.map(_.provinceId).toVector,
|
||||
actingFactionId,
|
||||
gs
|
||||
gs.provinces
|
||||
)
|
||||
.map { provinceAndDistance =>
|
||||
MCFOPWithDestinationAndDistance(
|
||||
@@ -96,7 +96,7 @@ object MoveLeaderToBetterProvinceCommandChooser {
|
||||
fid = actingFactionId,
|
||||
opmc = mcfopwd.mcfop,
|
||||
destinationProvinceId = mcfopwd.provinceAndDistance.provinceId,
|
||||
gs = GameStateConverter.fromProto(gs),
|
||||
gs = gs,
|
||||
favorLeaders = true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,32 +23,27 @@ import net.eagle0.eagle.common.diplomacy_offer_status.DiplomacyOfferStatus.{
|
||||
DIPLOMACY_OFFER_STATUS_IMPRISONED,
|
||||
DIPLOMACY_OFFER_STATUS_REJECTED
|
||||
}
|
||||
import net.eagle0.eagle.internal.faction_relationship.FactionRelationship.RelationshipLevel.ALLY
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.library.settings.{
|
||||
AiAllianceAcceptanceBaseChance,
|
||||
AiTruceAcceptanceBaseChance,
|
||||
MinimumPrestigeDifferenceToInvite
|
||||
}
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.{
|
||||
CommandChooser,
|
||||
CommandChooserImplicits,
|
||||
RansomOfferHelpers
|
||||
}
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.{CommandChooser, RansomOfferHelpers}
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.{
|
||||
randomSelectionForType,
|
||||
selectionForType
|
||||
}
|
||||
import net.eagle0.eagle.library.util.faction_utils.{FactionUtils, LegacyFactionUtils}
|
||||
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
|
||||
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.game_state.GameState
|
||||
import net.eagle0.eagle.model.state.province.ProvinceT
|
||||
import net.eagle0.eagle.FactionId
|
||||
|
||||
object ResolveDiplomacyCommandSelector {
|
||||
import CommandChooserImplicits.*
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.*
|
||||
|
||||
def resolveDiplomacySelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
@@ -106,8 +101,10 @@ object ResolveDiplomacyCommandSelector {
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
|
||||
val invitedFid = actingFactionId
|
||||
val factions = gs.factions.values.toVector
|
||||
val provinces = gs.provinces.values.toVector
|
||||
val bestInvitation = ac.invitations
|
||||
.maxBy(inv => invitationAcceptanceChance(inv.originatingFactionId, invitedFid, gs))
|
||||
.maxBy(inv => invitationAcceptanceChance(inv.originatingFactionId, invitedFid, factions, provinces))
|
||||
|
||||
internalRequire(
|
||||
bestInvitation.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_ACCEPTED),
|
||||
@@ -126,23 +123,14 @@ object ResolveDiplomacyCommandSelector {
|
||||
if 100.0 * roll < invitationAcceptanceChance(
|
||||
bestOriginatingFid,
|
||||
invitedFid,
|
||||
gs
|
||||
factions,
|
||||
provinces
|
||||
)
|
||||
then DIPLOMACY_OFFER_STATUS_ACCEPTED
|
||||
else DIPLOMACY_OFFER_STATUS_REJECTED
|
||||
)
|
||||
}
|
||||
|
||||
def invitationAcceptanceChance(
|
||||
originatingFid: FactionId,
|
||||
invitedFid: FactionId,
|
||||
gs: GameState
|
||||
): Int =
|
||||
(LegacyFactionUtils.prestige(originatingFid, gs)
|
||||
- LegacyFactionUtils.prestige(invitedFid, gs)
|
||||
- MinimumPrestigeDifferenceToInvite.doubleValue).floor.toInt.max(0)
|
||||
|
||||
/** Protoless version */
|
||||
def invitationAcceptanceChance(
|
||||
originatingFid: FactionId,
|
||||
invitedFid: FactionId,
|
||||
@@ -192,8 +180,10 @@ object ResolveDiplomacyCommandSelector {
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
|
||||
val actingFid = actingFactionId
|
||||
val factions = gs.factions.values.toVector
|
||||
val provinces = gs.provinces.values.toVector
|
||||
val bestOffer = ac.offers
|
||||
.maxBy(inv => truceOfferAcceptanceChance(inv.originatingFactionId, actingFid, gs))
|
||||
.maxBy(inv => truceOfferAcceptanceChance(inv.originatingFactionId, actingFid, factions, provinces))
|
||||
|
||||
internalRequire(
|
||||
bestOffer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_ACCEPTED),
|
||||
@@ -212,7 +202,8 @@ object ResolveDiplomacyCommandSelector {
|
||||
if 100.0 * roll < truceOfferAcceptanceChance(
|
||||
originatingFid = bestOriginatingFid,
|
||||
targetFid = actingFid,
|
||||
gs = gs
|
||||
factions = factions,
|
||||
provinces = provinces
|
||||
)
|
||||
then DIPLOMACY_OFFER_STATUS_ACCEPTED
|
||||
else DIPLOMACY_OFFER_STATUS_REJECTED
|
||||
@@ -222,51 +213,58 @@ object ResolveDiplomacyCommandSelector {
|
||||
def truceOfferAcceptanceChance(
|
||||
originatingFid: FactionId,
|
||||
targetFid: FactionId,
|
||||
gs: GameState
|
||||
): Int =
|
||||
factions: Vector[FactionT],
|
||||
provinces: Vector[ProvinceT]
|
||||
): Int = {
|
||||
val originatingFaction = factions.find(_.id == originatingFid).get
|
||||
val targetFaction = factions.find(_.id == targetFid).get
|
||||
(
|
||||
AiTruceAcceptanceBaseChance.doubleValue
|
||||
+ LegacyFactionUtils.prestige(originatingFid, gs)
|
||||
- LegacyFactionUtils.prestige(targetFid, gs)
|
||||
+ LegacyFactionUtils.trust(
|
||||
+ FactionUtils.prestige(originatingFaction, provinces)
|
||||
- FactionUtils.prestige(targetFaction, provinces)
|
||||
+ FactionUtils.trust(
|
||||
by = targetFid,
|
||||
of = originatingFid,
|
||||
gameState = gs
|
||||
factions = factions
|
||||
)
|
||||
).floor.toInt.max(0)
|
||||
}
|
||||
|
||||
def allianceOfferAcceptanceChance(
|
||||
originatingFid: FactionId,
|
||||
targetFid: FactionId,
|
||||
gs: GameState
|
||||
): Int =
|
||||
factions: Vector[FactionT],
|
||||
provinces: Vector[ProvinceT]
|
||||
): Int = {
|
||||
import net.eagle0.eagle.model.state.faction.FactionRelationship.RelationshipLevel.Ally
|
||||
val targetFaction = factions.find(_.id == targetFid).get
|
||||
val originatingFaction = factions.find(_.id == originatingFid).get
|
||||
val provincesMap = provinces.map(p => p.id -> p).toMap
|
||||
|
||||
// Always reject if the faction already has an alliance
|
||||
if gs
|
||||
.factions(targetFid)
|
||||
.factionRelationships
|
||||
.exists(_.relationshipLevel == ALLY)
|
||||
then 0
|
||||
if targetFaction.factionRelationships.exists(_.relationshipLevel == Ally) then 0
|
||||
|
||||
// reject if this is our only neighbor and we have no expansion possibilities
|
||||
else if LegacyFactionUtils.provinces(targetFid, gs).forall { province =>
|
||||
province.neighbors.map(n => gs.provinces(n.provinceId)).forall { neighborProvince =>
|
||||
neighborProvince.rulingFactionId.contains(
|
||||
originatingFid
|
||||
) || neighborProvince.rulingFactionId.contains(targetFid)
|
||||
else if FactionUtils.provinces(targetFid, provinces).forall { province =>
|
||||
province.neighbors.map(n => provincesMap(n.provinceId)).forall { neighborProvince =>
|
||||
neighborProvince.rulingFactionId.contains(originatingFid) ||
|
||||
neighborProvince.rulingFactionId.contains(targetFid)
|
||||
}
|
||||
}
|
||||
then 0
|
||||
else
|
||||
(
|
||||
AiAllianceAcceptanceBaseChance.doubleValue
|
||||
+ LegacyFactionUtils.prestige(originatingFid, gs)
|
||||
- LegacyFactionUtils.prestige(targetFid, gs)
|
||||
+ LegacyFactionUtils.trust(
|
||||
+ FactionUtils.prestige(originatingFaction, provinces)
|
||||
- FactionUtils.prestige(targetFaction, provinces)
|
||||
+ FactionUtils.trust(
|
||||
by = targetFid,
|
||||
of = originatingFid,
|
||||
gameState = gs
|
||||
factions = factions
|
||||
)
|
||||
).floor.toInt.max(0)
|
||||
end if
|
||||
}
|
||||
|
||||
private def resolveRansomOfferSelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
@@ -279,18 +277,14 @@ object ResolveDiplomacyCommandSelector {
|
||||
actingProvinceId = 0,
|
||||
available = resolveRansomAc,
|
||||
selected = selectionForResolveRansomOfferSelectedCommand(
|
||||
actingFactionId = actingFactionId,
|
||||
ac = resolveRansomAc,
|
||||
gs = gameState
|
||||
ac = resolveRansomAc
|
||||
),
|
||||
reason = "resolving ransom offer"
|
||||
)
|
||||
}
|
||||
|
||||
private def selectionForResolveRansomOfferSelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
ac: ResolveRansomOfferAvailableCommand,
|
||||
gs: GameState
|
||||
ac: ResolveRansomOfferAvailableCommand
|
||||
): SelectedCommand =
|
||||
ac.offers.head.offerDetails match {
|
||||
case rod: RansomOfferDetails =>
|
||||
@@ -343,9 +337,7 @@ object ResolveDiplomacyCommandSelector {
|
||||
) {
|
||||
case (resolveBreakAllianceAc, fr) =>
|
||||
selectionForResolveBreakAllianceSelectedCommand(
|
||||
actingFactionId = actingFactionId,
|
||||
ac = resolveBreakAllianceAc,
|
||||
gs = gameState,
|
||||
functionalRandom = fr
|
||||
).map { resolveBreakAllianceSelection =>
|
||||
Some(
|
||||
@@ -367,8 +359,10 @@ object ResolveDiplomacyCommandSelector {
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
|
||||
val actingFid = actingFactionId
|
||||
val factions = gs.factions.values.toVector
|
||||
val provinces = gs.provinces.values.toVector
|
||||
val bestOffer = ac.offers
|
||||
.maxBy(inv => allianceOfferAcceptanceChance(inv.originatingFactionId, actingFid, gs))
|
||||
.maxBy(inv => allianceOfferAcceptanceChance(inv.originatingFactionId, actingFid, factions, provinces))
|
||||
|
||||
internalRequire(
|
||||
bestOffer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_ACCEPTED),
|
||||
@@ -387,7 +381,8 @@ object ResolveDiplomacyCommandSelector {
|
||||
if 100.0 * roll < allianceOfferAcceptanceChance(
|
||||
originatingFid = bestOriginatingFid,
|
||||
targetFid = actingFid,
|
||||
gs = gs
|
||||
factions = factions,
|
||||
provinces = provinces
|
||||
)
|
||||
then DIPLOMACY_OFFER_STATUS_ACCEPTED
|
||||
else DIPLOMACY_OFFER_STATUS_REJECTED
|
||||
@@ -395,12 +390,11 @@ object ResolveDiplomacyCommandSelector {
|
||||
}
|
||||
|
||||
// FIXME: always imprison ambassadors for now
|
||||
// Note: doesn't use gameState or functionalRandom roll
|
||||
private def selectionForResolveBreakAllianceSelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
ac: ResolveBreakAllianceAvailableCommand,
|
||||
gs: GameState,
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
|
||||
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { _ =>
|
||||
val offer = ac.offers.head
|
||||
|
||||
ResolveBreakAllianceSelectedCommand(
|
||||
|
||||
@@ -28,9 +28,13 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle:__subpackages__",
|
||||
"//src/test/scala/net/eagle0/eagle:__subpackages__",
|
||||
],
|
||||
exports = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
|
||||
],
|
||||
deps = [
|
||||
":oauth_config",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
|
||||
"//src/main/scala/net/eagle0/common:simple_timed_logger",
|
||||
"@maven//:org_json4s_json4s_ast_3",
|
||||
"@maven//:org_json4s_json4s_core_3",
|
||||
"@maven//:org_json4s_json4s_native_3",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package net.eagle0.eagle.auth
|
||||
|
||||
object GenerateJwtKey {
|
||||
def main(args: Array[String]): Unit =
|
||||
println(JwtServiceImpl.generateKey().toJSONString)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import java.util.UUID
|
||||
import scala.collection.concurrent.TrieMap
|
||||
import scala.util.Try
|
||||
|
||||
import net.eagle0.common.SimpleTimedLogger
|
||||
import net.eagle0.eagle.api.auth.auth.OAuthProvider
|
||||
import org.json4s.{DefaultFormats, JObject, JString}
|
||||
import org.json4s.jvalue2extractable
|
||||
@@ -24,22 +25,30 @@ case class ProviderUserInfo(
|
||||
username: String
|
||||
)
|
||||
|
||||
/** Result of OAuth flow */
|
||||
sealed trait OAuthResult
|
||||
case class OAuthSuccess(providerInfo: ProviderUserInfo, provider: OAuthProvider) extends OAuthResult
|
||||
case class OAuthFailure(error: String) extends OAuthResult
|
||||
case object OAuthPending extends OAuthResult
|
||||
case object OAuthExpired extends OAuthResult
|
||||
|
||||
/** Service for OAuth code exchange */
|
||||
trait OAuthService {
|
||||
|
||||
/** Generate OAuth authorization URL */
|
||||
def getAuthUrl(provider: OAuthProvider, redirectUri: String): (String, String) // (url, state)
|
||||
/** Generate OAuth authorization URL. Server handles callback internally. */
|
||||
def getAuthUrl(provider: OAuthProvider): (String, String) // (url, state)
|
||||
|
||||
/** Exchange authorization code for user info */
|
||||
def exchangeCode(
|
||||
provider: OAuthProvider,
|
||||
code: String,
|
||||
state: String,
|
||||
redirectUri: String
|
||||
): Either[String, ProviderUserInfo]
|
||||
/** Check status of OAuth flow for given state */
|
||||
def checkStatus(state: String): OAuthResult
|
||||
|
||||
/** Handle OAuth callback - exchange code for user info and store result */
|
||||
def handleCallback(code: String, state: String): Either[String, ProviderUserInfo]
|
||||
|
||||
/** Get the server's OAuth callback URL */
|
||||
def callbackUrl: String
|
||||
}
|
||||
|
||||
class OAuthServiceImpl extends OAuthService {
|
||||
class OAuthServiceImpl(serverBaseUrl: String) extends OAuthService {
|
||||
|
||||
private implicit val formats: DefaultFormats.type = DefaultFormats
|
||||
private val json = new Json(formats)
|
||||
@@ -49,25 +58,32 @@ class OAuthServiceImpl extends OAuthService {
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build()
|
||||
|
||||
// State parameter storage with timestamp (for CSRF protection)
|
||||
private val stateStore = TrieMap[String, Long]()
|
||||
// Pending OAuth sessions: state -> (provider, timestamp)
|
||||
private val pendingOAuth = TrieMap[String, (OAuthProvider, Long)]()
|
||||
|
||||
// Completed OAuth results: state -> result
|
||||
private val completedOAuth = TrieMap[String, OAuthResult]()
|
||||
|
||||
// State expiration (10 minutes)
|
||||
private val stateExpirationMs = 600000L
|
||||
|
||||
override def getAuthUrl(provider: OAuthProvider, redirectUri: String): (String, String) = {
|
||||
override def callbackUrl: String = s"$serverBaseUrl/oauth/callback"
|
||||
|
||||
override def getAuthUrl(provider: OAuthProvider): (String, String) = {
|
||||
val config = getConfig(provider)
|
||||
|
||||
val state = UUID.randomUUID().toString
|
||||
stateStore.put(state, System.currentTimeMillis())
|
||||
pendingOAuth.put(state, (provider, System.currentTimeMillis()))
|
||||
SimpleTimedLogger.printLogger.logLine(
|
||||
s"OAuth: getAuthUrl created state=$state for provider=$provider (pending=${pendingOAuth.size}, completed=${completedOAuth.size})"
|
||||
)
|
||||
|
||||
// Clean old states
|
||||
val cutoff = System.currentTimeMillis() - stateExpirationMs
|
||||
stateStore.filterInPlace((_, timestamp) => timestamp > cutoff)
|
||||
cleanupExpiredStates()
|
||||
|
||||
val params = Map(
|
||||
"client_id" -> config.clientId,
|
||||
"redirect_uri" -> redirectUri,
|
||||
"redirect_uri" -> callbackUrl,
|
||||
"response_type" -> "code",
|
||||
"scope" -> config.scopes.mkString(" "),
|
||||
"state" -> state
|
||||
@@ -83,30 +99,90 @@ class OAuthServiceImpl extends OAuthService {
|
||||
(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")
|
||||
override def checkStatus(state: String): OAuthResult = {
|
||||
// First check completed results
|
||||
val result = completedOAuth.get(state) match {
|
||||
case Some(result) => result
|
||||
case None =>
|
||||
// Check if still pending
|
||||
pendingOAuth.get(state) match {
|
||||
case Some((_, timestamp)) if System.currentTimeMillis() - timestamp < stateExpirationMs =>
|
||||
OAuthPending
|
||||
case Some(_) =>
|
||||
// Expired
|
||||
pendingOAuth.remove(state)
|
||||
OAuthExpired
|
||||
case None =>
|
||||
// Unknown state - might be expired and cleaned up
|
||||
OAuthExpired
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
// Log non-pending results to help debug
|
||||
result match {
|
||||
case OAuthPending => // Don't spam logs with pending checks
|
||||
case _ =>
|
||||
SimpleTimedLogger.printLogger.logLine(
|
||||
s"OAuth: checkStatus state=$state result=$result (pending=${pendingOAuth.size}, completed=${completedOAuth.size}, inPending=${pendingOAuth.contains(state)}, inCompleted=${completedOAuth.contains(state)})"
|
||||
)
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
override def handleCallback(code: String, state: String): Either[String, ProviderUserInfo] = {
|
||||
SimpleTimedLogger.printLogger.logLine(
|
||||
s"OAuth: handleCallback state=$state (inPending=${pendingOAuth.contains(state)}, inCompleted=${completedOAuth.contains(state)})"
|
||||
)
|
||||
// Get pending OAuth info
|
||||
pendingOAuth.remove(state) match {
|
||||
case Some((provider, timestamp)) if System.currentTimeMillis() - timestamp < stateExpirationMs =>
|
||||
val config = getConfig(provider)
|
||||
|
||||
// Exchange code for access token
|
||||
val tokenResult = exchangeCodeForToken(config, code)
|
||||
tokenResult match {
|
||||
case Left(error) =>
|
||||
completedOAuth.put(state, OAuthFailure(error))
|
||||
Left(error)
|
||||
case Right(accessToken) =>
|
||||
// Fetch user info
|
||||
fetchUserInfo(provider, config, accessToken) match {
|
||||
case Left(error) =>
|
||||
completedOAuth.put(state, OAuthFailure(error))
|
||||
Left(error)
|
||||
case Right(userInfo) =>
|
||||
completedOAuth.put(state, OAuthSuccess(userInfo, provider))
|
||||
SimpleTimedLogger.printLogger.logLine(
|
||||
s"OAuth: handleCallback SUCCESS state=$state user=${userInfo.username} (completed now has ${completedOAuth.size} entries)"
|
||||
)
|
||||
Right(userInfo)
|
||||
}
|
||||
}
|
||||
|
||||
case Some(_) =>
|
||||
SimpleTimedLogger.printLogger.logLine(
|
||||
s"OAuth: handleCallback EXPIRED state=$state (was in pending but timestamp expired)"
|
||||
)
|
||||
completedOAuth.put(state, OAuthExpired)
|
||||
Left("OAuth session expired")
|
||||
|
||||
case None =>
|
||||
SimpleTimedLogger.printLogger.logLine(
|
||||
s"OAuth: handleCallback INVALID state=$state (not found in pending)"
|
||||
)
|
||||
Left("Invalid or expired state parameter")
|
||||
}
|
||||
}
|
||||
|
||||
private def cleanupExpiredStates(): Unit = {
|
||||
val cutoff = System.currentTimeMillis() - stateExpirationMs
|
||||
pendingOAuth.filterInPlace((_, v) => v._2 > cutoff)
|
||||
completedOAuth.filterInPlace((state, _) =>
|
||||
pendingOAuth.contains(state) ||
|
||||
completedOAuth.get(state).exists {
|
||||
case OAuthSuccess(_, _) => true // Keep successes for a bit
|
||||
case _ => false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private def getConfig(provider: OAuthProvider): OAuthProviderConfig =
|
||||
@@ -118,8 +194,7 @@ class OAuthServiceImpl extends OAuthService {
|
||||
|
||||
private def exchangeCodeForToken(
|
||||
config: OAuthProviderConfig,
|
||||
code: String,
|
||||
redirectUri: String
|
||||
code: String
|
||||
): Either[String, String] =
|
||||
Try {
|
||||
val formData = Map(
|
||||
@@ -127,7 +202,7 @@ class OAuthServiceImpl extends OAuthService {
|
||||
"client_secret" -> config.clientSecret,
|
||||
"grant_type" -> "authorization_code",
|
||||
"code" -> code,
|
||||
"redirect_uri" -> redirectUri
|
||||
"redirect_uri" -> callbackUrl
|
||||
)
|
||||
|
||||
val formBody = formData.map {
|
||||
|
||||
@@ -485,8 +485,6 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result/changed_province/concrete",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:action_result_concrete",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result/types:end_handle_riots_phase_result_type",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters:notification_converter",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/province",
|
||||
@@ -1372,7 +1370,6 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/province",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/province",
|
||||
],
|
||||
@@ -1404,7 +1401,6 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/province",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/province",
|
||||
],
|
||||
|
||||
+1
-4
@@ -12,7 +12,6 @@ import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedPro
|
||||
import net.eagle0.eagle.model.action_result.concrete.ActionResultC
|
||||
import net.eagle0.eagle.model.action_result.types.EndHandleRiotsPhaseResultType
|
||||
import net.eagle0.eagle.model.action_result.ActionResultT
|
||||
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.model.state.province.ProvinceT
|
||||
import net.eagle0.eagle.model.state.RoundPhase
|
||||
@@ -38,12 +37,10 @@ case class EndHandleRiotsPhaseAction(
|
||||
case (seq, province) =>
|
||||
commandsForProvince(province.id).map { opac =>
|
||||
seq.withRandomActionResults { (gs, fr) =>
|
||||
// Convert to proto for CommandChoiceHelpers which expects proto GameState
|
||||
val gsProto = GameStateConverter.toProto(gs)
|
||||
CommandChoiceHelpers
|
||||
.handleRiotSelectedCommand(
|
||||
actingFactionId = province.rulingFactionId.get,
|
||||
gameState = gsProto,
|
||||
gameState = gs,
|
||||
availableCommands = opac.commands.toVector,
|
||||
functionalRandom = fr
|
||||
)
|
||||
|
||||
+22
-31
@@ -4,8 +4,6 @@ import net.eagle0.common.{FunctionalRandom, MoreOption, RandomState}
|
||||
import net.eagle0.eagle.{FactionId, ProvinceId}
|
||||
import net.eagle0.eagle.api.available_command.{AvailableCommand, RestAvailableCommand}
|
||||
import net.eagle0.eagle.api.command.OneProvinceAvailableCommands
|
||||
import net.eagle0.eagle.common.province_order_type.ProvinceOrderType.*
|
||||
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
|
||||
import net.eagle0.eagle.library.actions.applier.ActionResultApplier
|
||||
import net.eagle0.eagle.library.actions.impl.command.TCommandFactory
|
||||
import net.eagle0.eagle.library.actions.impl.common.{ProtolessRandomSequentialResultsAction, TCommand}
|
||||
@@ -14,8 +12,8 @@ import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooser
|
||||
import net.eagle0.eagle.library.util.province.ProvinceUtils
|
||||
import net.eagle0.eagle.library.util.CommandSelection
|
||||
import net.eagle0.eagle.model.action_result.ActionResultT
|
||||
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.model.state.province.ProvinceOrderType.*
|
||||
import net.eagle0.eagle.model.state.province.ProvinceT
|
||||
|
||||
case class PerformVassalCommandsPhaseAction(
|
||||
@@ -75,60 +73,57 @@ case class PerformVassalCommandsPhaseAction(
|
||||
|
||||
private def maybeRestCommand(
|
||||
actingFactionId: FactionId,
|
||||
gsProto: GameStateProto,
|
||||
gs: GameState,
|
||||
commandOptions: Vector[AvailableCommand],
|
||||
provinceId: ProvinceId,
|
||||
reason: String
|
||||
): Option[CommandSelection] = {
|
||||
val heroes =
|
||||
gsProto
|
||||
.provinces(provinceId)
|
||||
.rulingFactionHeroIds
|
||||
.map(gsProto.heroes)
|
||||
gs.provinces(provinceId).rulingFactionHeroIds.map(gs.heroes)
|
||||
MoreOption.flatWhen(
|
||||
commandOptions.collectFirst {
|
||||
case ac: RestAvailableCommand =>
|
||||
ac
|
||||
}.isDefined && shouldRestProto(heroes)
|
||||
}.isDefined && shouldRest(heroes)
|
||||
) {
|
||||
chosenRestCommand(actingFactionId, gsProto, commandOptions, reason)
|
||||
chosenRestCommand(actingFactionId, gs, commandOptions, reason)
|
||||
}
|
||||
}
|
||||
|
||||
private def selectedCommandFromOrders(
|
||||
actingFactionId: FactionId,
|
||||
gsProto: GameStateProto,
|
||||
gs: GameState,
|
||||
commandOptions: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom,
|
||||
provinceId: ProvinceId
|
||||
): RandomState[Option[CommandSelection]] =
|
||||
gsProto.provinces(provinceId).provinceOrders match {
|
||||
case ENTRUST =>
|
||||
gs.provinces(provinceId).provinceOrders match {
|
||||
case Entrust =>
|
||||
chosenEntrustCommand(
|
||||
actingFactionId = actingFactionId,
|
||||
gameState = gsProto,
|
||||
gameState = gs,
|
||||
availableCommands = commandOptions,
|
||||
actingProvinceId = provinceId,
|
||||
functionalRandom = functionalRandom
|
||||
)
|
||||
case DEVELOP =>
|
||||
case Develop =>
|
||||
chosenDevelopCommand(
|
||||
actingFactionId = actingFactionId,
|
||||
gameState = gsProto,
|
||||
gameState = gs,
|
||||
availableCommands = commandOptions,
|
||||
functionalRandom = functionalRandom
|
||||
)
|
||||
case MOBILIZE =>
|
||||
case Mobilize =>
|
||||
chosenMobilizeCommand(
|
||||
actingFactionId = actingFactionId,
|
||||
gameState = gsProto,
|
||||
gameState = gs,
|
||||
availableCommands = commandOptions,
|
||||
functionalRandom = functionalRandom
|
||||
)
|
||||
case EXPAND =>
|
||||
case Expand =>
|
||||
chosenExpandCommand(
|
||||
actingFactionId,
|
||||
gsProto,
|
||||
gs,
|
||||
commandOptions,
|
||||
functionalRandom
|
||||
)
|
||||
@@ -137,7 +132,7 @@ case class PerformVassalCommandsPhaseAction(
|
||||
RandomState(
|
||||
chosenRestCommand(
|
||||
actingFactionId,
|
||||
gsProto,
|
||||
gs,
|
||||
commandOptions,
|
||||
"bad orders"
|
||||
),
|
||||
@@ -149,14 +144,11 @@ case class PerformVassalCommandsPhaseAction(
|
||||
gs: GameState,
|
||||
provinceId: ProvinceId,
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] = {
|
||||
// Convert to proto for CommandChoiceHelpers which expects proto GameState
|
||||
val gsProto = GameStateConverter.toProto(gs)
|
||||
|
||||
): RandomState[Option[CommandSelection]] =
|
||||
commandsForProvince(provinceId).map { oneProvinceAvailableCommands =>
|
||||
CommandChooser.choose(
|
||||
gs.provinces(provinceId).rulingFactionId.get,
|
||||
gsProto,
|
||||
gs,
|
||||
oneProvinceAvailableCommands.commands.toVector,
|
||||
Vector[CommandChooser](
|
||||
resolveTributeSelectedCommand,
|
||||
@@ -164,14 +156,14 @@ case class PerformVassalCommandsPhaseAction(
|
||||
handleRiotSelectedCommand,
|
||||
(
|
||||
fid: FactionId,
|
||||
gsP: GameStateProto,
|
||||
gsC: GameState,
|
||||
acs: Vector[AvailableCommand],
|
||||
fr: FunctionalRandom
|
||||
) =>
|
||||
RandomState(
|
||||
maybeRestCommand(
|
||||
fid,
|
||||
gsP,
|
||||
gsC,
|
||||
acs,
|
||||
provinceId,
|
||||
"chosen vassal command: rest"
|
||||
@@ -180,13 +172,12 @@ case class PerformVassalCommandsPhaseAction(
|
||||
),
|
||||
(
|
||||
fid: FactionId,
|
||||
gsP: GameStateProto,
|
||||
gsC: GameState,
|
||||
acs: Vector[AvailableCommand],
|
||||
fr: FunctionalRandom
|
||||
) => selectedCommandFromOrders(fid, gsP, acs, fr, provinceId)
|
||||
) => selectedCommandFromOrders(fid, gsC, acs, fr, provinceId)
|
||||
),
|
||||
functionalRandom
|
||||
)
|
||||
}.get
|
||||
}
|
||||
}
|
||||
|
||||
+3
-8
@@ -10,7 +10,6 @@ import net.eagle0.eagle.library.util.command_choice_helpers.CommandChoiceHelpers
|
||||
import net.eagle0.eagle.library.util.province.ProvinceUtils
|
||||
import net.eagle0.eagle.library.util.CommandSelection
|
||||
import net.eagle0.eagle.model.action_result.ActionResultT
|
||||
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.model.state.province.ProvinceT
|
||||
import net.eagle0.eagle.ProvinceId
|
||||
@@ -72,13 +71,10 @@ case class PerformVassalDefenseDecisionsAction(
|
||||
provinceId: ProvinceId,
|
||||
commandOptions: Seq[net.eagle0.eagle.api.available_command.AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] = {
|
||||
// Convert to proto for CommandChoiceHelpers which expects proto GameState
|
||||
val gsProto = GameStateConverter.toProto(gs)
|
||||
|
||||
): RandomState[Option[CommandSelection]] =
|
||||
CommandChoiceHelpers.resolveTributeSelectedCommand(
|
||||
actingFactionId = gs.provinces(provinceId).rulingFactionId.get,
|
||||
gameState = gsProto,
|
||||
gameState = gs,
|
||||
availableCommands = commandOptions.toVector,
|
||||
functionalRandom = functionalRandom
|
||||
) match {
|
||||
@@ -86,10 +82,9 @@ case class PerformVassalDefenseDecisionsAction(
|
||||
case RandomState(None, fr) =>
|
||||
CommandChoiceHelpers.defendSelectedCommand(
|
||||
gs.provinces(provinceId).rulingFactionId.get,
|
||||
gsProto,
|
||||
gs,
|
||||
commandOptions.toVector,
|
||||
fr
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,7 +495,6 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/client_text:client_text_store",
|
||||
"//src/main/scala/net/eagle0/eagle/client_text:text_generation_result",
|
||||
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_type_id_converter",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
)
|
||||
|
||||
+1
-2
@@ -32,7 +32,6 @@ import net.eagle0.eagle.library.actions.llm_prompt_generators.HeroDescriptionGen
|
||||
descriptionWithoutFaction
|
||||
}
|
||||
import net.eagle0.eagle.library.EagleInternalException
|
||||
import net.eagle0.eagle.model.proto_converters.BattalionTypeIdConverter
|
||||
import net.eagle0.eagle.model.state.faction.FactionT
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.model.state.hero.HeroT
|
||||
@@ -196,7 +195,7 @@ object QuestEndedGeneratorUtilities {
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
val battType =
|
||||
gameState.battalionTypes.find(_.typeId == BattalionTypeIdConverter.fromProto(battalionTypeId)).get
|
||||
gameState.battalionTypes.find(_.typeId == battalionTypeId).get
|
||||
val targetProvince = gameState.provinces(provinceId)
|
||||
for {
|
||||
unaffiliatedHeroName <- unaffiliatedHeroNameResult
|
||||
|
||||
@@ -2,6 +2,9 @@ package net.eagle0.eagle.library.util
|
||||
|
||||
import net.eagle0.eagle.internal.army.{Army, HostileArmyGroup, MovingArmy}
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.model.state.{Army as ArmyC, HostileArmyGroup as HostileArmyGroupC, MovingArmy as MovingArmyC}
|
||||
import net.eagle0.eagle.model.state.battalion.BattalionT
|
||||
import net.eagle0.eagle.BattalionId
|
||||
|
||||
object ArmyUtils {
|
||||
def heroCount(armyGroup: HostileArmyGroup): Int =
|
||||
@@ -23,4 +26,32 @@ object ArmyUtils {
|
||||
.map(gs.battalions)
|
||||
.map(_.size)
|
||||
.sum
|
||||
|
||||
// ==================== Protoless overloads ====================
|
||||
|
||||
/** Protoless overload */
|
||||
def heroCount(armyGroup: HostileArmyGroupC): Int =
|
||||
armyGroup.armies.map(heroCountC).sum
|
||||
|
||||
/** Protoless overload */
|
||||
def heroCountC(movingArmy: MovingArmyC): Int = heroCount(movingArmy.army)
|
||||
|
||||
/** Protoless overload */
|
||||
def heroCount(army: ArmyC): Int = army.units.size
|
||||
|
||||
/** Protoless overload */
|
||||
def troopCount(armyGroup: HostileArmyGroupC, battalions: Map[BattalionId, BattalionT]): Int =
|
||||
armyGroup.armies.map(ma => troopCount(ma, battalions)).sum
|
||||
|
||||
/** Protoless overload */
|
||||
def troopCount(movingArmy: MovingArmyC, battalions: Map[BattalionId, BattalionT]): Int =
|
||||
troopCount(movingArmy.army, battalions)
|
||||
|
||||
/** Protoless overload */
|
||||
def troopCount(army: ArmyC, battalions: Map[BattalionId, BattalionT]): Int =
|
||||
army.units
|
||||
.flatMap(_.battalionId)
|
||||
.map(battalions)
|
||||
.map(_.size)
|
||||
.sum
|
||||
}
|
||||
|
||||
@@ -8,10 +8,18 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library:__subpackages__",
|
||||
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
|
||||
],
|
||||
exports = [
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state:army",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:army_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:battalion_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/model/state:army",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -47,6 +55,7 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
|
||||
"//src/main/scala/net/eagle0/eagle/model/view/battalion",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -189,17 +198,26 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/ai:__subpackages__",
|
||||
"//src/main/scala/net/eagle0/eagle/library:__subpackages__",
|
||||
],
|
||||
exports = ["//src/main/scala/net/eagle0/eagle:eagle_pkg"],
|
||||
exports = [
|
||||
"//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/model/state:army",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/common:hostility_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api/command/util:army_stats_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/util:battalion_power",
|
||||
"//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/proto_converters:battalion_converter",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state:army",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/province",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package net.eagle0.eagle.library.util
|
||||
|
||||
import net.eagle0.eagle.model.state.battalion.BattalionT
|
||||
import net.eagle0.eagle.model.state.BattalionTypeId
|
||||
import net.eagle0.eagle.model.view.battalion.BattalionView as BattalionViewC
|
||||
|
||||
object BattalionPower {
|
||||
// When we don't know enemy battalion composition, assume Heavy Infantry/Light Cavalry (1.5)
|
||||
@@ -53,4 +54,12 @@ object BattalionPower {
|
||||
// Formula: typeMultiplier * (0.5 + armament/100) * (0.5 + training/100) * size
|
||||
// = 1.5 * (0.5 + 0.5) * (0.5 + 0.5) * 1 = 1.5 * 1.0 * 1.0 * 1 = 1.5
|
||||
BASELINE_UNIT_TYPE_MULTIPLIER * 1.0 * 1.0 * 1
|
||||
|
||||
// Calculate estimated power for a BattalionView (recon data), using defaults when stats unknown
|
||||
def estimatedPower(battalionView: BattalionViewC): Double = powerForInfo(
|
||||
battalionTypeId = battalionView.typeId,
|
||||
size = battalionView.size,
|
||||
armament = battalionView.armament.getOrElse(50.0),
|
||||
training = battalionView.training.getOrElse(50.0)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,9 +5,12 @@ import net.eagle0.eagle.api.command.util.army_stats.ArmyStats
|
||||
import net.eagle0.eagle.internal.army.{Army, MovingArmy}
|
||||
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, LegacyFactionUtils}
|
||||
import net.eagle0.eagle.library.util.BattalionPower
|
||||
import net.eagle0.eagle.model.proto_converters.BattalionConverter
|
||||
import net.eagle0.eagle.model.state.{Army as ArmyC, MovingArmy as MovingArmyC}
|
||||
import net.eagle0.eagle.model.state.game_state.GameState as GameStateC
|
||||
import net.eagle0.eagle.model.state.province.ProvinceT
|
||||
|
||||
object IncomingArmyUtils {
|
||||
def hasRelevantIncomingArmy(
|
||||
@@ -65,6 +68,21 @@ object IncomingArmyUtils {
|
||||
.isHostileProvince(province, ma.getArmy.factionId, gameState)
|
||||
)
|
||||
|
||||
/** Protoless overload */
|
||||
def isUnderAttack(
|
||||
province: net.eagle0.eagle.model.state.province.ProvinceT,
|
||||
gameState: net.eagle0.eagle.model.state.game_state.GameState
|
||||
): Boolean = {
|
||||
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
|
||||
province.incomingArmies
|
||||
.filter(_.arrivalRound == gameState.currentRoundId)
|
||||
.exists(ma =>
|
||||
province.rulingFactionId.exists(targetFid =>
|
||||
FactionUtils.factionsAreHostile(targetFid, ma.army.factionId, gameState.factions.values.toVector)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
def stats(
|
||||
toFid: FactionId,
|
||||
army: Army,
|
||||
@@ -88,4 +106,75 @@ object IncomingArmyUtils {
|
||||
val battalions = army.units.flatMap(_.battalionId).map(gs.battalions)
|
||||
battalions.map(BattalionConverter.fromProto).map(BattalionPower.power).sum
|
||||
}
|
||||
|
||||
// Scala overload for protoless code
|
||||
def armyPower(
|
||||
army: net.eagle0.eagle.model.state.Army,
|
||||
gs: net.eagle0.eagle.model.state.game_state.GameState
|
||||
): Double = {
|
||||
val battalions = army.units.flatMap(_.battalionId).map(gs.battalions)
|
||||
battalions.map(BattalionPower.power).sum
|
||||
}
|
||||
|
||||
// ==================== Protoless overloads ====================
|
||||
|
||||
/** Protoless overload */
|
||||
def hasRelevantIncomingArmy(
|
||||
province: ProvinceT,
|
||||
factionId: FactionId,
|
||||
currentRoundId: RoundId
|
||||
): Boolean =
|
||||
province.incomingArmies.exists(ia => ia.army.factionId == factionId && ia.arrivalRound == currentRoundId)
|
||||
|
||||
/** Protoless overload */
|
||||
def relevantIncomingArmies(
|
||||
province: ProvinceT,
|
||||
factionId: FactionId,
|
||||
currentRoundId: RoundId
|
||||
): Vector[MovingArmyC] =
|
||||
province.incomingArmies
|
||||
.filter(ia => ia.army.factionId == factionId && ia.arrivalRound == currentRoundId)
|
||||
|
||||
/** Protoless overload */
|
||||
def incomingArmiesAreMutuallyAllied(
|
||||
province: ProvinceT,
|
||||
gameState: GameStateC
|
||||
): Boolean =
|
||||
FactionUtils.factionsAreMutuallyAllied(
|
||||
fids = province.incomingArmies
|
||||
.filter(_.arrivalRound == gameState.currentRoundId)
|
||||
.map(_.army.factionId) ++ province.hostileArmies
|
||||
.flatMap(_.armies)
|
||||
.map(_.army.factionId),
|
||||
factions = gameState.factions.values.toVector
|
||||
)
|
||||
|
||||
/** Protoless overload */
|
||||
def isHostileProvince(
|
||||
province: ProvinceT,
|
||||
factionId: FactionId,
|
||||
gameState: GameStateC
|
||||
): Boolean =
|
||||
province.rulingFactionId.exists(targetFid =>
|
||||
FactionUtils.factionsAreHostile(targetFid, factionId, gameState.factions.values.toVector)
|
||||
)
|
||||
|
||||
/** Protoless overload */
|
||||
def stats(
|
||||
toFid: FactionId,
|
||||
army: ArmyC,
|
||||
gs: GameStateC,
|
||||
originProvinceId: ProvinceId
|
||||
): ArmyStats = {
|
||||
val battalions = army.units.flatMap(_.battalionId).map(gs.battalions)
|
||||
val troopCount = battalions.map(_.size).sum
|
||||
|
||||
ArmyStats(
|
||||
factionId = army.factionId,
|
||||
heroCount = army.units.size,
|
||||
troopCount = troopCount,
|
||||
hostility = FactionUtils.hostilityStatus(toFid, army.factionId, gs.factions.values.toVector),
|
||||
originProvinceId = originProvinceId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -9,13 +9,13 @@ import net.eagle0.eagle.library.settings.{
|
||||
RequiredRatioToAttack,
|
||||
RequiredRatioToAttackWithoutTakingCastles
|
||||
}
|
||||
import net.eagle0.eagle.library.util.{BattalionPower, CommandSelection, LegacyBattalionUtils, ShardokMapInfo}
|
||||
import net.eagle0.eagle.library.util.{BattalionPower, CommandSelection, ShardokMapInfo}
|
||||
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.model.state.hero.HeroT
|
||||
import net.eagle0.eagle.model.state.hero.Profession.{Engineer, Mage}
|
||||
import net.eagle0.eagle.model.state.province.ProvinceT
|
||||
import net.eagle0.eagle.views.battalion_view.BattalionView
|
||||
import net.eagle0.eagle.model.view.battalion.BattalionView as BattalionViewC
|
||||
|
||||
object AttackCommandChooser {
|
||||
|
||||
@@ -211,7 +211,7 @@ object AttackCommandChooser {
|
||||
gameState: GameState,
|
||||
acs: Vector[AvailableCommand],
|
||||
knownEnemyHeroCount: ProvinceId => Int,
|
||||
knownEnemyBattalions: ProvinceId => Vector[BattalionView]
|
||||
knownEnemyBattalions: ProvinceId => Vector[BattalionViewC]
|
||||
): Option[CommandSelection] =
|
||||
// find march commands to an enemy-occupied territory
|
||||
flatSelectionForType[MarchAvailableCommand](acs) { ac =>
|
||||
@@ -251,7 +251,7 @@ object AttackCommandChooser {
|
||||
case (dPid, commandInfos) =>
|
||||
val expectedEnemyBattalionPower =
|
||||
knownEnemyBattalions(dPid)
|
||||
.map(LegacyBattalionUtils.estimatedPower)
|
||||
.map(BattalionPower.estimatedPower)
|
||||
.takeRight(knownEnemyHeroCount(dPid))
|
||||
.sum
|
||||
|
||||
|
||||
+16
-20
@@ -11,12 +11,10 @@ import net.eagle0.eagle.api.command.util.attack_decision_type.{
|
||||
}
|
||||
import net.eagle0.eagle.api.selected_command.AttackDecisionSelectedCommand
|
||||
import net.eagle0.eagle.common.tribute_amount.TributeAmount
|
||||
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
|
||||
import net.eagle0.eagle.library.settings.MinimumPercentageOfEnemyToAttack
|
||||
import net.eagle0.eagle.library.util.{BattalionPower, CommandSelection, IncomingArmyUtils}
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.flatSelectionForType
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.*
|
||||
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.FactionId
|
||||
|
||||
@@ -26,45 +24,44 @@ object AttackDecisionCommandChooser {
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] = {
|
||||
val protoGameState = GameStateConverter.toProto(gameState)
|
||||
): RandomState[Option[CommandSelection]] =
|
||||
CommandChooser.choose(
|
||||
actingFactionId = actingFactionId,
|
||||
gameState = protoGameState,
|
||||
gameState = gameState,
|
||||
availableCommands = availableCommands,
|
||||
rankedChoosers = Vector[CommandChooser](
|
||||
(actingFactionId, protoGs, availableCommands, functionalRandom) =>
|
||||
(actingFactionId, gs, availableCommands, functionalRandom) =>
|
||||
RandomState(
|
||||
withdrawArmySelectedCommand(
|
||||
actingFactionId,
|
||||
protoGs,
|
||||
gs,
|
||||
availableCommands
|
||||
),
|
||||
functionalRandom
|
||||
),
|
||||
(actingFactionId, protoGs, availableCommands, functionalRandom) =>
|
||||
(actingFactionId, gs, availableCommands, functionalRandom) =>
|
||||
RandomState(
|
||||
demandTributeSelectedCommand(
|
||||
actingFactionId,
|
||||
protoGs,
|
||||
gs,
|
||||
availableCommands
|
||||
),
|
||||
functionalRandom
|
||||
),
|
||||
(actingFactionId, protoGs, availableCommands, functionalRandom) =>
|
||||
(actingFactionId, gs, availableCommands, functionalRandom) =>
|
||||
RandomState(
|
||||
advanceArmySelectedCommand(
|
||||
actingFactionId,
|
||||
protoGs,
|
||||
gs,
|
||||
availableCommands
|
||||
),
|
||||
functionalRandom
|
||||
),
|
||||
(actingFactionId, protoGs, availableCommands, functionalRandom) =>
|
||||
(actingFactionId, gs, availableCommands, functionalRandom) =>
|
||||
RandomState(
|
||||
safePassageSelectedCommand(
|
||||
actingFactionId,
|
||||
protoGs,
|
||||
gs,
|
||||
availableCommands
|
||||
),
|
||||
functionalRandom
|
||||
@@ -72,11 +69,10 @@ object AttackDecisionCommandChooser {
|
||||
),
|
||||
functionalRandom = functionalRandom
|
||||
)
|
||||
}
|
||||
|
||||
private def withdrawArmySelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
flatSelectionForType[AttackDecisionAvailableCommand](availableCommands) { withdrawAc =>
|
||||
@@ -102,7 +98,7 @@ object AttackDecisionCommandChooser {
|
||||
|
||||
private def demandTributeSelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
flatSelectionForType[AttackDecisionAvailableCommand](availableCommands) { availableCommand =>
|
||||
@@ -133,7 +129,7 @@ object AttackDecisionCommandChooser {
|
||||
|
||||
private def okToAttack(
|
||||
availableCommand: AttackDecisionAvailableCommand,
|
||||
gameState: GameStateProto
|
||||
gameState: GameState
|
||||
): Boolean = {
|
||||
val armies = availableCommand.armies
|
||||
val province = gameState.provinces(availableCommand.actingProvinceId)
|
||||
@@ -147,7 +143,7 @@ object AttackDecisionCommandChooser {
|
||||
.find(hag => hag.factionId == armyStats.factionId)
|
||||
.map(_.armies)
|
||||
.getOrElse(Vector.empty)
|
||||
.map(ia => IncomingArmyUtils.armyPower(ia.getArmy, gameState))
|
||||
.map(ia => IncomingArmyUtils.armyPower(ia.army, gameState))
|
||||
.sum
|
||||
)
|
||||
.sum
|
||||
@@ -178,7 +174,7 @@ object AttackDecisionCommandChooser {
|
||||
|
||||
private def advanceArmySelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
flatSelectionForType[AttackDecisionAvailableCommand](availableCommands) { availableCommand =>
|
||||
@@ -206,7 +202,7 @@ object AttackDecisionCommandChooser {
|
||||
|
||||
private def safePassageSelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
flatSelectionForType[AttackDecisionAvailableCommand](availableCommands) { availableCommand =>
|
||||
|
||||
@@ -63,20 +63,18 @@ scala_library(
|
||||
":march_supplies_helpers",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/views:battalion_view_scala_proto",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:min_vigor_for_march",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:required_ratio_to_attack",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:required_ratio_to_attack_without_taking_castles",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:battalion_power",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:battalion_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
|
||||
"//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/faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/hero",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/province",
|
||||
"//src/main/scala/net/eagle0/eagle/model/view/battalion",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -95,14 +93,12 @@ scala_library(
|
||||
":command_chooser",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
|
||||
"//src/main/scala/net/eagle0/common:functional_random",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:minimum_percentage_of_enemy_to_attack",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:battalion_power",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:incoming_army_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
)
|
||||
@@ -151,13 +147,14 @@ scala_library(
|
||||
],
|
||||
exports = [
|
||||
"//src/main/scala/net/eagle0/common:functional_random",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
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/scala/net/eagle0/common:functional_random",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -193,9 +190,6 @@ scala_library(
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api/command/util:armed_battalion_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api/command/util:battalion_with_food_cost_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:hero_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:province_scala_proto",
|
||||
"//src/main/scala/net/eagle0/common:functional_random",
|
||||
"//src/main/scala/net/eagle0/common:more_option",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
@@ -219,23 +213,18 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/food_consumption",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/province",
|
||||
"//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:round_phase_converter",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/date",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/province",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/date",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/faction",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/hero",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/province",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/quest",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -300,8 +289,6 @@ scala_library(
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
|
||||
"//src/main/scala/net/eagle0/common:functional_random",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
|
||||
@@ -316,10 +303,9 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/quest_command_selectors:truce_count_quest_command_chooser",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/quest_command_selectors:truce_with_faction_quest_command_chooser",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/quest_command_selectors:unaffiliated_hero_with_quest",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters:quest_converter",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/province",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/unaffiliated_hero",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
+171
-242
@@ -13,19 +13,10 @@ import net.eagle0.eagle.api.command.util.captured_hero_option.CapturedHeroOption
|
||||
import net.eagle0.eagle.api.command.util.diplomacy_option.RansomOfferOption
|
||||
import net.eagle0.eagle.api.selected_command.*
|
||||
import net.eagle0.eagle.api.selected_command.OrganizeTroopsSelectedCommand.{ChangedBattalion, NewBattalion}
|
||||
import net.eagle0.eagle.common.battalion_type.{BattalionType, BattalionTypeId}
|
||||
import net.eagle0.eagle.common.battalion_type.BattalionTypeId
|
||||
import net.eagle0.eagle.common.combat_unit.CombatUnit
|
||||
import net.eagle0.eagle.common.improvement_type.ImprovementType.INFRASTRUCTURE
|
||||
import net.eagle0.eagle.common.recruitment_info.RecruitmentStatus.{
|
||||
RECRUITMENT_STATUS_NOT_DIVINED,
|
||||
RECRUITMENT_STATUS_WOULD_JOIN
|
||||
}
|
||||
import net.eagle0.eagle.common.tribute_amount.TributeAmount
|
||||
import net.eagle0.eagle.common.unaffiliated_hero_type.UnaffiliatedHeroType.UNAFFILIATED_HERO_PRISONER
|
||||
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.internal.unaffiliated_hero.UnaffiliatedHero
|
||||
import net.eagle0.eagle.library.settings.{
|
||||
BaseFoodBuyPrice,
|
||||
DesiredLoyaltyOverThreshold,
|
||||
@@ -42,26 +33,21 @@ import net.eagle0.eagle.library.settings.{
|
||||
import net.eagle0.eagle.library.util.{BattalionUtils, CommandSelection, IncomingArmyUtils, ProvinceDistances}
|
||||
import net.eagle0.eagle.library.util.battalion_type_finder.BattalionTypeFinder
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.randomSelectionForType
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.*
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.ProvinceGoldSurplusCalculator.{
|
||||
goldToHoldBack,
|
||||
provinceGoldSurplus
|
||||
}
|
||||
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
|
||||
import net.eagle0.eagle.library.util.food_consumption.FoodConsumptionUtils
|
||||
import net.eagle0.eagle.library.util.hero.{HeroUtils, LegacyHeroUtils}
|
||||
import net.eagle0.eagle.library.util.hero.HeroUtils
|
||||
import net.eagle0.eagle.library.util.province.ProvinceUtils
|
||||
import net.eagle0.eagle.model.proto_converters.{BattalionConverter, BattalionTypeConverter, RoundPhaseConverter}
|
||||
import net.eagle0.eagle.model.proto_converters.date.DateConverter
|
||||
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
|
||||
import net.eagle0.eagle.model.proto_converters.hero.HeroConverter
|
||||
import net.eagle0.eagle.model.proto_converters.province.ProvinceConverter
|
||||
import net.eagle0.eagle.model.state.{BattalionType as BattalionTypeC, BattalionTypeId as BattalionTypeIdC}
|
||||
import net.eagle0.eagle.model.state.faction.FactionT
|
||||
import net.eagle0.eagle.model.state.game_state.GameState as GameStateC
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.model.state.hero.HeroT
|
||||
import net.eagle0.eagle.model.state.hero.Profession.NoProfession
|
||||
import net.eagle0.eagle.model.state.province.ProvinceT
|
||||
import net.eagle0.eagle.model.state.unaffiliated_hero.{RecruitmentInfo, UnaffiliatedHeroT, UnaffiliatedHeroType}
|
||||
|
||||
object CommandChoiceHelpers {
|
||||
import AvailableCommandSelector.{flatSelectionForType, selectionForType}
|
||||
@@ -81,7 +67,7 @@ object CommandChoiceHelpers {
|
||||
OrganizeCommandSelector.selectedOrganizeCommand(
|
||||
actingFactionId = actingFactionId,
|
||||
availableCommand = organizeCommand,
|
||||
gameState = GameStateConverter.fromProto(gameState),
|
||||
gameState = gameState,
|
||||
extraFoodSurplus = gameState.provinces(organizeCommand.actingProvinceId).food,
|
||||
reason = "organizing because we're under attack"
|
||||
)
|
||||
@@ -92,19 +78,16 @@ object CommandChoiceHelpers {
|
||||
actingProvinceId: ProvinceId,
|
||||
gameState: GameState
|
||||
): Option[Supplies] = {
|
||||
val gs = GameStateConverter.fromProto(gameState)
|
||||
|
||||
def battalionType(battalionTypeId: BattalionTypeIdC): BattalionTypeC =
|
||||
BattalionTypeFinder.battalionType(battalionTypeId, gs.battalionTypes)
|
||||
BattalionTypeFinder.battalionType(battalionTypeId, gameState.battalionTypes)
|
||||
|
||||
val province = gameState.provinces(actingProvinceId)
|
||||
val provinceC = gs.provinces(actingProvinceId)
|
||||
val province = gameState.provinces(actingProvinceId)
|
||||
|
||||
MoreOption.flatWhen(province.support >= MinSupportForTaxes.doubleValue) {
|
||||
val heroCount = province.rulingFactionHeroIds.size
|
||||
val battalionCount = province.battalionIds.size
|
||||
val neededNewBattalions: Int = (heroCount - battalionCount).max(0)
|
||||
val existingBattalions = provinceC.battalionIds.map(gs.battalions)
|
||||
val existingBattalions = province.battalionIds.map(gameState.battalions)
|
||||
|
||||
val hcType = battalionType(BattalionTypeIdC.HeavyCavalry)
|
||||
|
||||
@@ -127,7 +110,7 @@ object CommandChoiceHelpers {
|
||||
|
||||
val goldExcess =
|
||||
(province.gold - (fillUpBattalionsCost + newBattalionsCost + maximizeExistingBattalionArmamentCost + newBattalionArmamentCost + goldToHoldBack(
|
||||
provinceC
|
||||
province
|
||||
)))
|
||||
.max(0)
|
||||
.floor
|
||||
@@ -185,13 +168,7 @@ object CommandChoiceHelpers {
|
||||
availableCommands = availableCommands,
|
||||
rankedChoosers = Vector[CommandChooser](
|
||||
chosenCommandWhileTraveling,
|
||||
(actingFactionId, gameState, availableCommands, functionalRandom) =>
|
||||
AttackDecisionCommandChooser.attackDecisionSelectedCommand(
|
||||
actingFactionId,
|
||||
GameStateConverter.fromProto(gameState),
|
||||
availableCommands,
|
||||
functionalRandom
|
||||
),
|
||||
AttackDecisionCommandChooser.attackDecisionSelectedCommand,
|
||||
(actingFactionId, gameState, availableCommands, functionalRandom) =>
|
||||
RandomState(
|
||||
chosenUnderAttackCommand(
|
||||
@@ -225,7 +202,7 @@ object CommandChoiceHelpers {
|
||||
RandomState(
|
||||
AlmsCommandSelector.chosenAlmsForSupportCommand(
|
||||
actingFactionId = actingFactionId,
|
||||
gameState = GameStateConverter.fromProto(gameState),
|
||||
gameState = gameState,
|
||||
availableCommands = availableCommands
|
||||
),
|
||||
functionalRandom
|
||||
@@ -269,7 +246,7 @@ object CommandChoiceHelpers {
|
||||
RandomState(
|
||||
ImproveCommandSelector.chosenImproveCommand(
|
||||
actingFactionId,
|
||||
GameStateConverter.fromProto(gameState),
|
||||
gameState,
|
||||
availableCommands
|
||||
),
|
||||
functionalRandom
|
||||
@@ -285,7 +262,7 @@ object CommandChoiceHelpers {
|
||||
functionalRandom
|
||||
)
|
||||
),
|
||||
functionalRandom
|
||||
functionalRandom = functionalRandom
|
||||
)
|
||||
|
||||
def chosenMobilizeCommand(
|
||||
@@ -313,7 +290,7 @@ object CommandChoiceHelpers {
|
||||
RandomState(
|
||||
OrganizeCommandSelector.chosenOrganizeCommand(
|
||||
actingFactionId,
|
||||
GameStateConverter.fromProto(gameState),
|
||||
gameState,
|
||||
availableCommands,
|
||||
"mobilizing"
|
||||
),
|
||||
@@ -346,7 +323,7 @@ object CommandChoiceHelpers {
|
||||
RandomState(
|
||||
ImproveCommandSelector.chosenImproveCommand(
|
||||
actingFactionId,
|
||||
GameStateConverter.fromProto(gameState),
|
||||
gameState,
|
||||
availableCommands
|
||||
),
|
||||
functionalRandom
|
||||
@@ -378,31 +355,17 @@ object CommandChoiceHelpers {
|
||||
availableCommands = availableCommands,
|
||||
rankedChoosers = Vector[CommandChooser](
|
||||
chosenUniversalCommand,
|
||||
(
|
||||
fid: FactionId,
|
||||
gs: GameState,
|
||||
acs: Vector[AvailableCommand],
|
||||
fr: FunctionalRandom
|
||||
) =>
|
||||
chosenMobilizeIfAdjacentEnemyCommand(
|
||||
fid,
|
||||
gs,
|
||||
acs,
|
||||
actingProvinceId,
|
||||
fr
|
||||
),
|
||||
(fid, gs, acs, fr) => chosenMobilizeIfAdjacentEnemyCommand(fid, gs, acs, actingProvinceId, fr),
|
||||
chosenDevelopCommand
|
||||
),
|
||||
functionalRandom = functionalRandom
|
||||
)
|
||||
|
||||
/** Protoless version */
|
||||
private def suppressBeastsValue(hero: HeroT): Double =
|
||||
(hero.strength + hero.agility) / 2.0 + (if hero.profession == NoProfession
|
||||
then 0.0
|
||||
else 50.0)
|
||||
|
||||
/** Protoless version - calculates beast power for a province */
|
||||
private def beastPower(province: ProvinceT): Int = {
|
||||
val beastCount = ProvinceUtils.beastCount(province)
|
||||
val beastInfo = ProvinceUtils.beastInfo(province).get
|
||||
@@ -416,14 +379,12 @@ object CommandChoiceHelpers {
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] = {
|
||||
val gs = GameStateConverter.fromProto(gameState)
|
||||
): Option[CommandSelection] =
|
||||
flatSelectionForType[SuppressBeastsAvailableCommand](availableCommands) { suppressAc =>
|
||||
val actingPid = suppressAc.actingProvinceId
|
||||
|
||||
val province = gameState.provinces(actingPid)
|
||||
val provinceC = gs.provinces(actingPid)
|
||||
val beastPwr = beastPower(provinceC)
|
||||
val province = gameState.provinces(actingPid)
|
||||
val beastPwr = beastPower(province)
|
||||
|
||||
if province.battalionIds
|
||||
.exists(b => gameState.battalions(b).size >= beastPwr)
|
||||
@@ -431,7 +392,7 @@ object CommandChoiceHelpers {
|
||||
val leaderId = HeroSelector
|
||||
.minimallyFatiguedHeroes(
|
||||
suppressAc.availableHeroIds
|
||||
.map(gs.heroes)
|
||||
.map(gameState.heroes)
|
||||
.toVector
|
||||
)
|
||||
.maxBy(suppressBeastsValue(_))
|
||||
@@ -461,7 +422,6 @@ object CommandChoiceHelpers {
|
||||
)
|
||||
end if
|
||||
}
|
||||
}
|
||||
|
||||
private def chosenMobilizeIfAdjacentEnemyCommand(
|
||||
actingFactionId: FactionId,
|
||||
@@ -470,10 +430,9 @@ object CommandChoiceHelpers {
|
||||
actingProvinceId: ProvinceId,
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] = {
|
||||
val gs = GameStateConverter.fromProto(gameState)
|
||||
val province = gs.provinces(actingProvinceId)
|
||||
val province = gameState.provinces(actingProvinceId)
|
||||
if ProvinceUtils
|
||||
.adjacentHostiles(province, gs.provinces)
|
||||
.adjacentHostiles(province, gameState.provinces)
|
||||
.nonEmpty
|
||||
then {
|
||||
chosenMobilizeCommand(
|
||||
@@ -497,28 +456,12 @@ object CommandChoiceHelpers {
|
||||
availableCommands = availableCommands,
|
||||
rankedChoosers = Vector[CommandChooser](
|
||||
chosenUniversalCommand,
|
||||
(
|
||||
fid: FactionId,
|
||||
gs: GameState,
|
||||
acs: Vector[AvailableCommand],
|
||||
fr: FunctionalRandom
|
||||
) =>
|
||||
ExpandCommandSelector.maybeChosenExpandCommand(
|
||||
fid,
|
||||
GameStateConverter.fromProto(gs),
|
||||
acs,
|
||||
fr
|
||||
),
|
||||
(
|
||||
fid: FactionId,
|
||||
gs: GameState,
|
||||
acs: Vector[AvailableCommand],
|
||||
fr: FunctionalRandom
|
||||
) =>
|
||||
(fid, gs, acs, fr) => ExpandCommandSelector.maybeChosenExpandCommand(fid, gs, acs, fr),
|
||||
(fid, gs, acs, fr) =>
|
||||
RandomState(
|
||||
AttackCommandChooser.chosenAttackCommand(
|
||||
actingFactionId = fid,
|
||||
gameState = GameStateConverter.fromProto(gs),
|
||||
gameState = gs,
|
||||
acs = acs,
|
||||
knownEnemyHeroCount = _ => 0,
|
||||
knownEnemyBattalions = _ => Vector()
|
||||
@@ -529,7 +472,7 @@ object CommandChoiceHelpers {
|
||||
RandomState(
|
||||
ImproveCommandSelector.chosenImproveCommand(
|
||||
actingFactionId,
|
||||
GameStateConverter.fromProto(gameState),
|
||||
gameState,
|
||||
availableCommands
|
||||
),
|
||||
functionalRandom
|
||||
@@ -553,8 +496,7 @@ object CommandChoiceHelpers {
|
||||
gs: GameState,
|
||||
acs: Vector[AvailableCommand],
|
||||
reason: String
|
||||
): Option[CommandSelection] = {
|
||||
val gsc = GameStateConverter.fromProto(gs)
|
||||
): Option[CommandSelection] =
|
||||
chosenRestCommand(
|
||||
actingFactionId = actingFactionId,
|
||||
gameState = gs,
|
||||
@@ -563,17 +505,15 @@ object CommandChoiceHelpers {
|
||||
).filter {
|
||||
case CommandSelection(_, _, ac, _, _) =>
|
||||
shouldRest(
|
||||
gsc
|
||||
gs
|
||||
.provinces(
|
||||
ac.asInstanceOf[RestAvailableCommand].actingProvinceId
|
||||
)
|
||||
.rulingFactionHeroIds
|
||||
.map(gsc.heroes)
|
||||
.map(gs.heroes)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Protoless version */
|
||||
def shouldRest(heroes: Iterable[HeroT]): Boolean =
|
||||
if heroes
|
||||
.map(HeroUtils.fatigue)
|
||||
@@ -581,17 +521,9 @@ object CommandChoiceHelpers {
|
||||
then true
|
||||
else false
|
||||
|
||||
/** Legacy proto version */
|
||||
def shouldRestProto(heroes: Iterable[Hero]): Boolean =
|
||||
if heroes
|
||||
.map(LegacyHeroUtils.fatigue)
|
||||
.min >= VassalCommandsMaxFatigueLevelBeforeResting.doubleValue
|
||||
then true
|
||||
else false
|
||||
|
||||
def chosenRestCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
@annotation.nowarn("msg=unused explicit parameter") gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
reason: String
|
||||
): Option[CommandSelection] =
|
||||
@@ -607,7 +539,15 @@ object CommandChoiceHelpers {
|
||||
|
||||
def handleCapturedHeroesSelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
gs: GameState,
|
||||
@annotation.nowarn("msg=unused explicit parameter") gameState: GameState,
|
||||
acs: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] =
|
||||
// gameState not used, delegate to core implementation
|
||||
handleCapturedHeroesSelectedCommandImpl(actingFactionId, acs, functionalRandom)
|
||||
|
||||
private def handleCapturedHeroesSelectedCommandImpl(
|
||||
actingFactionId: FactionId,
|
||||
acs: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] =
|
||||
@@ -650,7 +590,14 @@ object CommandChoiceHelpers {
|
||||
|
||||
def resolvePleaseRecruitMeSelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
@annotation.nowarn("msg=unused explicit parameter") gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
// gameState not used, delegate to core implementation
|
||||
resolvePleaseRecruitMeSelectedCommandImpl(actingFactionId, availableCommands)
|
||||
|
||||
private def resolvePleaseRecruitMeSelectedCommandImpl(
|
||||
actingFactionId: FactionId,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
selectionForType[PleaseRecruitMeAvailableCommand](availableCommands) { pleaseRecruitMeAc =>
|
||||
@@ -669,7 +616,7 @@ object CommandChoiceHelpers {
|
||||
|
||||
private def handleRiotGiveSelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
@annotation.nowarn("msg=unused explicit parameter") gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
selectionForType[HandleRiotGiveAvailableCommand](availableCommands) { availableCommand =>
|
||||
@@ -714,7 +661,7 @@ object CommandChoiceHelpers {
|
||||
|
||||
private def handleRiotDoNothingSelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
@annotation.nowarn("msg=unused explicit parameter") gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
selectionForType[HandleRiotDoNothingAvailableCommand](availableCommands) { availableCommand =>
|
||||
@@ -771,40 +718,26 @@ object CommandChoiceHelpers {
|
||||
|
||||
def freeForAllDecisionSelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
@annotation.nowarn("msg=unused explicit parameter") gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] =
|
||||
CommandChooser.choose(
|
||||
actingFactionId = actingFactionId,
|
||||
gameState = gameState,
|
||||
availableCommands = availableCommands,
|
||||
rankedChoosers = Vector[CommandChooser](
|
||||
(actingFactionId, gameState, availableCommands, functionalRandom) =>
|
||||
RandomState(
|
||||
advanceFFASelectedCommand(
|
||||
actingFactionId,
|
||||
gameState,
|
||||
availableCommands
|
||||
),
|
||||
functionalRandom
|
||||
),
|
||||
(actingFactionId, gameState, availableCommands, functionalRandom) =>
|
||||
RandomState(
|
||||
withdrawFFASelectedCommand(
|
||||
actingFactionId,
|
||||
gameState,
|
||||
availableCommands
|
||||
),
|
||||
functionalRandom
|
||||
)
|
||||
),
|
||||
functionalRandom = functionalRandom
|
||||
)
|
||||
// gameState not used by inner methods, delegate to core implementation
|
||||
freeForAllDecisionSelectedCommandImpl(actingFactionId, availableCommands, functionalRandom)
|
||||
|
||||
private def freeForAllDecisionSelectedCommandImpl(
|
||||
actingFactionId: FactionId,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] =
|
||||
advanceFFASelectedCommand(actingFactionId, availableCommands) match {
|
||||
case Some(cs) => RandomState(Some(cs), functionalRandom)
|
||||
case None =>
|
||||
RandomState(withdrawFFASelectedCommand(actingFactionId, availableCommands), functionalRandom)
|
||||
}
|
||||
|
||||
private def withdrawFFASelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
flatSelectionForType[FreeForAllDecisionAvailableCommand](
|
||||
@@ -828,7 +761,6 @@ object CommandChoiceHelpers {
|
||||
|
||||
private def advanceFFASelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
flatSelectionForType[FreeForAllDecisionAvailableCommand](
|
||||
@@ -852,7 +784,7 @@ object CommandChoiceHelpers {
|
||||
|
||||
def resolveTributeSelectedCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
@annotation.nowarn("msg=unused explicit parameter") gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] =
|
||||
@@ -930,30 +862,29 @@ object CommandChoiceHelpers {
|
||||
CombatUnitSelector.selectedCombatBattalions(
|
||||
desiredCount = availableCommand.availableHeroIds.size
|
||||
.min(MaxCombatUnitCountPerSide.intValue),
|
||||
heroes = availableCommand.availableHeroIds.map(gs.heroes).map(HeroConverter.fromProto).toVector,
|
||||
heroes = availableCommand.availableHeroIds.map(gs.heroes).toVector,
|
||||
battalions = availableCommand.availableBattalions
|
||||
.map(_.battalionId)
|
||||
.map(gs.battalions)
|
||||
.map(BattalionConverter.fromProto)
|
||||
.toVector,
|
||||
battalionTypes = gs.battalionTypes.map(BattalionTypeConverter.fromProto).toVector
|
||||
battalionTypes = gs.battalionTypes
|
||||
)
|
||||
|
||||
def foodAmountToBuy(
|
||||
province: Province,
|
||||
province: ProvinceT,
|
||||
gameState: GameState,
|
||||
goldAvailable: Int,
|
||||
foodBuyPrice: Double
|
||||
): Option[Int] = {
|
||||
val provinceC = ProvinceConverter.fromProto(province)
|
||||
val battalions = province.battalionIds.map(gameState.battalions).map(BattalionConverter.fromProto).toVector
|
||||
val battalionTypes = gameState.battalionTypes.map(BattalionTypeConverter.fromProto).toVector
|
||||
val date = DateConverter.fromProto(gameState.currentDate)
|
||||
val roundPhase = RoundPhaseConverter.fromProto(gameState.currentPhase)
|
||||
val battalions = province.battalionIds.map(gameState.battalions).toVector
|
||||
val battalionTypes = gameState.battalionTypes
|
||||
|
||||
val monthsRemainingInYear = FoodConsumptionUtils.foodConsumptionMonthsRemainingInYear(date, roundPhase)
|
||||
val monthsRemainingInYear = FoodConsumptionUtils.foodConsumptionMonthsRemainingInYear(
|
||||
gameState.currentDate.get,
|
||||
gameState.currentPhase
|
||||
)
|
||||
val monthsToHold =
|
||||
if provinceC.support >= MinSupportForTaxes.doubleValue
|
||||
if province.support >= MinSupportForTaxes.doubleValue
|
||||
then monthsRemainingInYear
|
||||
else monthsRemainingInYear.max(6)
|
||||
|
||||
@@ -1022,25 +953,24 @@ object CommandChoiceHelpers {
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] = {
|
||||
val gs = GameStateConverter.fromProto(gameState)
|
||||
): Option[CommandSelection] =
|
||||
flatSelectionForType[ImproveAvailableCommand](availableCommands) { availableCommand =>
|
||||
val provinceC = gs.provinces(availableCommand.actingProvinceId)
|
||||
val province = gameState.provinces(availableCommand.actingProvinceId)
|
||||
// If we don't have enough gold that we would arm troops anyway, bail
|
||||
if provinceGoldSurplus(provinceC) < MinGoldSurplusForArm.intValue
|
||||
if provinceGoldSurplus(province) < MinGoldSurplusForArm.intValue
|
||||
then None
|
||||
else {
|
||||
val battalions = provinceC.battalionIds.map(gs.battalions)
|
||||
val battalions = province.battalionIds.map(gameState.battalions)
|
||||
val battalionCount = battalions.size
|
||||
// If fewer than half the battalions have training > infrastructure, raise the infrastructure so that we can arm
|
||||
MoreOption.flatWhen(
|
||||
battalions.count(
|
||||
_.training > ProvinceUtils.effectiveInfrastructure(provinceC)
|
||||
_.training > ProvinceUtils.effectiveInfrastructure(province)
|
||||
) < battalionCount / 2
|
||||
) {
|
||||
ImproveCommandSelector.chosenSpecificImprovementCommand(
|
||||
actingFactionId,
|
||||
gs,
|
||||
gameState,
|
||||
availableCommands,
|
||||
INFRASTRUCTURE
|
||||
)
|
||||
@@ -1048,18 +978,15 @@ object CommandChoiceHelpers {
|
||||
}
|
||||
end if
|
||||
}
|
||||
}
|
||||
|
||||
def suppliesToSend(provinceId: ProvinceId, gameState: GameState): Supplies = {
|
||||
val gs = GameStateConverter.fromProto(gameState)
|
||||
val province = gs.provinces(provinceId)
|
||||
val province = gameState.provinces(provinceId)
|
||||
Supplies(
|
||||
food = provinceFoodSurplus(province, gs),
|
||||
food = provinceFoodSurplus(province, gameState),
|
||||
gold = provinceGoldSurplus(province)
|
||||
)
|
||||
}
|
||||
|
||||
/** Protoless version */
|
||||
private def closestLeader(
|
||||
actingFactionId: FactionId,
|
||||
faction: FactionT,
|
||||
@@ -1095,9 +1022,8 @@ object CommandChoiceHelpers {
|
||||
gameState: GameState,
|
||||
ac: SendSuppliesAvailableCommand
|
||||
): Option[ProvinceId] = {
|
||||
val gs = GameStateConverter.fromProto(gameState)
|
||||
val faction = gs.factions(actingFactionId)
|
||||
val provinces = gs.provinces
|
||||
val faction = gameState.factions(actingFactionId)
|
||||
val provinces = gameState.provinces
|
||||
faction.focusProvinceId
|
||||
.orElse(
|
||||
closestLeader(
|
||||
@@ -1124,8 +1050,7 @@ object CommandChoiceHelpers {
|
||||
ac: SendSuppliesAvailableCommand,
|
||||
supplies: Supplies,
|
||||
destination: ProvinceId
|
||||
): CommandSelection = {
|
||||
val gs = GameStateConverter.fromProto(gameState)
|
||||
): CommandSelection =
|
||||
CommandSelection(
|
||||
actingFactionId = actingFactionId,
|
||||
actingProvinceId = ac.actingProvinceId,
|
||||
@@ -1135,11 +1060,10 @@ object CommandChoiceHelpers {
|
||||
food = supplies.food,
|
||||
gold = supplies.gold,
|
||||
actingHeroId = ac.availableHeroIds
|
||||
.minBy(hid => HeroUtils.fatigue(gs.heroes(hid)))
|
||||
.minBy(hid => HeroUtils.fatigue(gameState.heroes(hid)))
|
||||
),
|
||||
reason = "chosenSendSuppliesCommandWithSuppliesAndDestination"
|
||||
)
|
||||
}
|
||||
|
||||
def chosenSendSuppliesCommandWithSupplies(
|
||||
actingFactionId: FactionId,
|
||||
@@ -1171,7 +1095,6 @@ object CommandChoiceHelpers {
|
||||
)
|
||||
)
|
||||
|
||||
/** Protoless version */
|
||||
private def lowLoyaltyHeroes(
|
||||
heroes: Vector[HeroT],
|
||||
factions: Vector[FactionT]
|
||||
@@ -1214,35 +1137,32 @@ object CommandChoiceHelpers {
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
reason: String
|
||||
): Option[CommandSelection] = {
|
||||
val gs = GameStateConverter.fromProto(gameState)
|
||||
): Option[CommandSelection] =
|
||||
flatSelectionForType[FeastAvailableCommand](availableCommands) { feastAvailableCommand =>
|
||||
val actingProvinceId = feastAvailableCommand.actingProvinceId
|
||||
val province = gs.provinces(actingProvinceId)
|
||||
val province = gameState.provinces(actingProvinceId)
|
||||
val heroes = lowLoyaltyHeroes(
|
||||
heroes = province.rulingFactionHeroIds.map(gs.heroes),
|
||||
factions = gs.factions.values.toVector
|
||||
heroes = province.rulingFactionHeroIds.map(gameState.heroes),
|
||||
factions = gameState.factions.values.toVector
|
||||
)
|
||||
Option.when(heroes.length > 1)(
|
||||
chosenFeastCommand(actingFactionId, feastAvailableCommand, reason)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private def maybeChosenGiftCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
reason: String
|
||||
): Option[CommandSelection] = {
|
||||
val gs = GameStateConverter.fromProto(gameState)
|
||||
): Option[CommandSelection] =
|
||||
flatSelectionForType[HeroGiftAvailableCommand](availableCommands) { heroGiftAvailableCommand =>
|
||||
val heroIds = heroGiftAvailableCommand.eligibleGifts
|
||||
.map(_.recipientHeroId)
|
||||
.toVector
|
||||
lowLoyaltyHeroes(
|
||||
heroes = heroIds.map(gs.heroes),
|
||||
factions = gs.factions.values.toVector
|
||||
heroes = heroIds.map(gameState.heroes),
|
||||
factions = gameState.factions.values.toVector
|
||||
).headOption.flatMap { hero =>
|
||||
HeroGiftCommandSelector.chosenCommandForSpecificHero(
|
||||
actingFactionId = actingFactionId,
|
||||
@@ -1252,7 +1172,6 @@ object CommandChoiceHelpers {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private def maybeSpreadIntoOwnedProvince(
|
||||
opmc: MarchCommandFromOneProvince,
|
||||
@@ -1260,22 +1179,20 @@ object CommandChoiceHelpers {
|
||||
gameState: GameState,
|
||||
ac: MarchAvailableCommand
|
||||
): Option[CommandSelection] = {
|
||||
val gs = GameStateConverter.fromProto(gameState)
|
||||
val originProvince = gameState.provinces(opmc.originProvinceId)
|
||||
val originProvinceC = gs.provinces(opmc.originProvinceId)
|
||||
val originProvince = gameState.provinces(opmc.originProvinceId)
|
||||
opmc.availableDestinationProvinces
|
||||
.map(dest => gameState.provinces(dest.provinceId))
|
||||
.filter(_.rulingFactionId.contains(actingFactionId))
|
||||
.filter(p => p.rulingFactionHeroIds.length < p.heroCap)
|
||||
.maxByOption(p => p.rulingFactionHeroIds.length - p.heroCap)
|
||||
.map { destProvince =>
|
||||
val foodToHoldBack = ProvinceUtils.monthlyFoodConsumption(originProvinceC, gs) *
|
||||
FoodConsumptionUtils.foodConsumptionMonthsToHold(originProvinceC, gs)
|
||||
val foodToHoldBack = ProvinceUtils.monthlyFoodConsumption(originProvince, gameState) *
|
||||
FoodConsumptionUtils.foodConsumptionMonthsToHold(originProvince, gameState)
|
||||
|
||||
val foodToTake = Math.max(0, (originProvince.food - foodToHoldBack) / 2)
|
||||
val goldToTake = Math.max(
|
||||
0,
|
||||
(originProvince.gold - provinceGoldSurplus(originProvinceC).max(0)) / 2
|
||||
(originProvince.gold - provinceGoldSurplus(originProvince).max(0)) / 2
|
||||
)
|
||||
|
||||
CommandSelection(
|
||||
@@ -1288,7 +1205,7 @@ object CommandChoiceHelpers {
|
||||
originProvince = opmc.originProvinceId,
|
||||
destinationProvinceId = destProvince.id,
|
||||
marchingUnits = opmc.availableHeroIds
|
||||
.sortBy(hid => HeroUtils.power(gs.heroes(hid)))
|
||||
.sortBy(hid => HeroUtils.power(gameState.heroes(hid)))
|
||||
.take(
|
||||
originProvince.rulingFactionHeroIds.length - originProvince.heroCap
|
||||
)
|
||||
@@ -1312,7 +1229,6 @@ object CommandChoiceHelpers {
|
||||
ac: MarchAvailableCommand,
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] = {
|
||||
val gs = GameStateConverter.fromProto(gameState)
|
||||
val originProvince = gameState.provinces(opmc.originProvinceId)
|
||||
val sendingHeroCount = (originProvince.rulingFactionHeroIds.length -
|
||||
originProvince.heroCap).min(opmc.availableHeroIds.length)
|
||||
@@ -1349,7 +1265,7 @@ object CommandChoiceHelpers {
|
||||
originProvince = opmc.originProvinceId,
|
||||
destinationProvinceId = destProvince.id,
|
||||
marchingUnits = opmc.availableHeroIds
|
||||
.sortBy(hid => HeroUtils.power(gs.heroes(hid)))
|
||||
.sortBy(hid => HeroUtils.power(gameState.heroes(hid)))
|
||||
.take(sendingHeroCount)
|
||||
.zipAll(
|
||||
sendingBattalions.map(batt => Some(batt.battalionId)),
|
||||
@@ -1424,14 +1340,13 @@ object CommandChoiceHelpers {
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] = {
|
||||
val gs = GameStateConverter.fromProto(gameState)
|
||||
// To avoid multiple ransoms going out from the same faction, make this dependent on the current round ID
|
||||
val myProvinces = gameState.provinces.values
|
||||
.filter(_.rulingFactionId.contains(actingFactionId))
|
||||
.toVector
|
||||
val chosenIndex = gameState.currentRoundId % myProvinces.length
|
||||
val chosenPid = myProvinces(chosenIndex).id
|
||||
val faction = gs.factions(actingFactionId)
|
||||
val faction = gameState.factions(actingFactionId)
|
||||
|
||||
randomSelectionForType[DiplomacyAvailableCommand](
|
||||
availableCommands,
|
||||
@@ -1461,7 +1376,7 @@ object CommandChoiceHelpers {
|
||||
originatingFactionId = actingFactionId,
|
||||
targetFactionId = targetFactionId,
|
||||
availableOffer = ransomOffer,
|
||||
gameState = GameStateConverter.fromProto(gameState)
|
||||
gameState = gameState
|
||||
)
|
||||
.map { chosenOffer =>
|
||||
CommandSelection(
|
||||
@@ -1495,11 +1410,11 @@ object CommandChoiceHelpers {
|
||||
// Only rescue the leader as a universal command if *all* faction leaders are captured
|
||||
(if gameState
|
||||
.factions(actingFactionId)
|
||||
.leaders
|
||||
.leaderIds
|
||||
.forall(hid =>
|
||||
gameState.provinces.values.exists(
|
||||
_.unaffiliatedHeroes
|
||||
.exists(uh => uh.heroId == hid && uh.`type` == UNAFFILIATED_HERO_PRISONER)
|
||||
.exists(uh => uh.heroId == hid && uh.unaffiliatedHeroType == UnaffiliatedHeroType.Prisoner)
|
||||
)
|
||||
)
|
||||
then
|
||||
@@ -1533,7 +1448,13 @@ object CommandChoiceHelpers {
|
||||
),
|
||||
functionalRandom
|
||||
),
|
||||
maybeRansomLeaderCommand
|
||||
(actingFactionId, gameState, availableCommands, functionalRandom) =>
|
||||
maybeRansomLeaderCommand(
|
||||
actingFactionId,
|
||||
gameState,
|
||||
availableCommands,
|
||||
functionalRandom
|
||||
)
|
||||
),
|
||||
functionalRandom = functionalRandom
|
||||
)
|
||||
@@ -1551,7 +1472,13 @@ object CommandChoiceHelpers {
|
||||
gameState = gameState,
|
||||
availableCommands = availableCommands,
|
||||
rankedChoosers = Vector[CommandChooser](
|
||||
maybeChosenGetUnderHeroCapCommand,
|
||||
(actingFactionId, gameState, availableCommands, functionalRandom) =>
|
||||
maybeChosenGetUnderHeroCapCommand(
|
||||
actingFactionId,
|
||||
gameState,
|
||||
availableCommands,
|
||||
functionalRandom
|
||||
),
|
||||
(actingFactionId, gameState, availableCommands, functionalRandom) =>
|
||||
RandomState(
|
||||
maybeChosenFeastCommand(
|
||||
@@ -1659,7 +1586,13 @@ object CommandChoiceHelpers {
|
||||
|
||||
private def maybeChosenRecruitCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
@annotation.nowarn("msg=unused explicit parameter") gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
maybeChosenRecruitCommandImpl(actingFactionId, availableCommands)
|
||||
|
||||
private def maybeChosenRecruitCommandImpl(
|
||||
actingFactionId: FactionId,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
flatSelectionForType[RecruitHeroesAvailableCommand](availableCommands) { availableCommand =>
|
||||
@@ -1681,11 +1614,10 @@ object CommandChoiceHelpers {
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] = {
|
||||
val gs = GameStateConverter.fromProto(gameState)
|
||||
): Option[CommandSelection] =
|
||||
flatSelectionForType[DivineAvailableCommand](availableCommands) { availableCommand =>
|
||||
val divinableHeroes = availableCommand.divinableHeroes
|
||||
val provinceC = gs.provinces(availableCommand.actingProvinceId)
|
||||
val provinceC = gameState.provinces(availableCommand.actingProvinceId)
|
||||
val availableGold = provinceGoldSurplus(provinceC)
|
||||
val heroCountToDivine = availableGold / availableCommand.costPerHero
|
||||
|
||||
@@ -1696,7 +1628,7 @@ object CommandChoiceHelpers {
|
||||
actingProvinceId = availableCommand.actingProvinceId,
|
||||
selected = DivineSelectedCommand(
|
||||
heroIds = divinableHeroes
|
||||
.sortBy(uh => HeroUtils.power(gs.heroes(uh.getHero.id)))
|
||||
.sortBy(uh => HeroUtils.power(gameState.heroes(uh.getHero.id)))
|
||||
.takeRight(heroCountToDivine)
|
||||
.map(_.getHero.id)
|
||||
),
|
||||
@@ -1704,11 +1636,16 @@ object CommandChoiceHelpers {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private def chosenReturnCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
@annotation.nowarn("msg=unused explicit parameter") gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
chosenReturnCommandImpl(actingFactionId, availableCommands)
|
||||
|
||||
private def chosenReturnCommandImpl(
|
||||
actingFactionId: FactionId,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
selectionForType[ReturnAvailableCommand](availableCommands) { availableCommand =>
|
||||
@@ -1725,18 +1662,17 @@ object CommandChoiceHelpers {
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] = {
|
||||
val gs = GameStateConverter.fromProto(gameState)
|
||||
): Option[CommandSelection] =
|
||||
flatSelectionForType[TravelAvailableCommand](availableCommands) { availableCommand =>
|
||||
val actingProvinceId = availableCommand.actingProvinceId
|
||||
val provinceC = gs.provinces(actingProvinceId)
|
||||
val province = gameState.provinces(actingProvinceId)
|
||||
|
||||
// Don't travel if you don't have much extra gold to spend anyway
|
||||
if provinceGoldSurplus(provinceC) < MinGoldSurplusForArm.intValue
|
||||
if provinceGoldSurplus(province) < MinGoldSurplusForArm.intValue
|
||||
then None
|
||||
else {
|
||||
val battalions = provinceC.battalionIds.map(gs.battalions)
|
||||
val effectiveInfra = ProvinceUtils.effectiveInfrastructure(provinceC)
|
||||
val battalions = province.battalionIds.map(gameState.battalions)
|
||||
val effectiveInfra = ProvinceUtils.effectiveInfrastructure(province)
|
||||
val highestArmamentWeShouldUpgrade =
|
||||
if effectiveInfra > 90 then effectiveInfra - 3
|
||||
else effectiveInfra - 10
|
||||
@@ -1746,7 +1682,6 @@ object CommandChoiceHelpers {
|
||||
)(chosenTravelCommand(actingFactionId, availableCommand))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def maybeMoveToRecruitCommand(
|
||||
actingFactionId: FactionId,
|
||||
@@ -1756,8 +1691,7 @@ object CommandChoiceHelpers {
|
||||
): RandomState[Option[CommandSelection]] =
|
||||
randomSelectionForType[MarchAvailableCommand](acs, functionalRandom) {
|
||||
case (availableCommand, fr) =>
|
||||
val gsc = GameStateConverter.fromProto(gs)
|
||||
val factionsVector = gsc.factions.values.toVector
|
||||
val factionsVector = gs.factions.values.toVector
|
||||
val presentLeaders = gs
|
||||
.provinces(availableCommand.actingProvinceId)
|
||||
.rulingFactionHeroIds
|
||||
@@ -1783,7 +1717,7 @@ object CommandChoiceHelpers {
|
||||
fromHere.get.availableDestinationProvinces
|
||||
.map(dest => gs.provinces(dest.provinceId))
|
||||
.filter(_.rulingFactionId.contains(actingFactionId))
|
||||
.filterNot(p => ProvinceUtils.containsFactionLeader(gsc.provinces(p.id), factionsVector))
|
||||
.filterNot(p => ProvinceUtils.containsFactionLeader(p, factionsVector))
|
||||
.filter(p => hasInterestingUH(p.id, gs))
|
||||
|
||||
val moveableLeaders = fromHere.get.availableHeroIds
|
||||
@@ -1800,7 +1734,7 @@ object CommandChoiceHelpers {
|
||||
(
|
||||
interestingUHs.length,
|
||||
interestingUHs
|
||||
.map(uh => HeroUtils.power(gsc.heroes(uh.heroId)))
|
||||
.map(uh => HeroUtils.power(gs.heroes(uh.heroId)))
|
||||
.sum
|
||||
)
|
||||
}
|
||||
@@ -1836,8 +1770,8 @@ object CommandChoiceHelpers {
|
||||
end if
|
||||
}
|
||||
|
||||
private def isInterestingUH(uh: UnaffiliatedHero): Boolean =
|
||||
uh.recruitmentInfo.get.status == RECRUITMENT_STATUS_WOULD_JOIN || uh.recruitmentInfo.get.status == RECRUITMENT_STATUS_NOT_DIVINED
|
||||
private def isInterestingUH(uh: UnaffiliatedHeroT): Boolean =
|
||||
uh.recruitmentInfo == RecruitmentInfo.WouldJoin || uh.recruitmentInfo == RecruitmentInfo.Unknown
|
||||
|
||||
private def hasInterestingUH(provinceId: ProvinceId, gs: GameState): Boolean =
|
||||
gs.provinces(provinceId)
|
||||
@@ -1848,15 +1782,14 @@ object CommandChoiceHelpers {
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] = {
|
||||
val gs = GameStateConverter.fromProto(gameState)
|
||||
): Option[CommandSelection] =
|
||||
flatSelectionForType[TravelAvailableCommand](availableCommands) { availableCommand =>
|
||||
val actingProvinceId = availableCommand.actingProvinceId
|
||||
|
||||
Option.when(
|
||||
ProvinceUtils.containsFactionLeader(
|
||||
gs.provinces(actingProvinceId),
|
||||
gs.factions.values
|
||||
gameState.provinces(actingProvinceId),
|
||||
gameState.factions.values
|
||||
) && hasInterestingUH(
|
||||
actingProvinceId,
|
||||
gameState
|
||||
@@ -1866,7 +1799,6 @@ object CommandChoiceHelpers {
|
||||
}
|
||||
}
|
||||
.map(_.withReason("travel to recruit"))
|
||||
}
|
||||
|
||||
private def chosenTravelCommand(
|
||||
actingFactionId: FactionId,
|
||||
@@ -1882,7 +1814,13 @@ object CommandChoiceHelpers {
|
||||
|
||||
def chosenTrainCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
@annotation.nowarn("msg=unused explicit parameter") gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
chosenTrainCommandImpl(actingFactionId, availableCommands)
|
||||
|
||||
private def chosenTrainCommandImpl(
|
||||
actingFactionId: FactionId,
|
||||
availableCommands: Vector[AvailableCommand]
|
||||
): Option[CommandSelection] =
|
||||
selectionForType[TrainAvailableCommand](availableCommands) { availableCommand =>
|
||||
@@ -1904,9 +1842,7 @@ object CommandChoiceHelpers {
|
||||
beastPower: Int
|
||||
): Option[CommandSelection] =
|
||||
flatSelectionForType[OrganizeTroopsAvailableCommand](availableCommands) { availableCommand =>
|
||||
val province = gameState.provinces(
|
||||
availableCommand.actingProvinceId
|
||||
)
|
||||
val province = gameState.provinces(availableCommand.actingProvinceId)
|
||||
val costs = availableCommand.troopCosts
|
||||
.map(tc => tc.`type` -> tc.costPerTroop)
|
||||
.toMap
|
||||
@@ -1938,11 +1874,10 @@ object CommandChoiceHelpers {
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
reason: String
|
||||
): Option[CommandSelection] = {
|
||||
val gs = GameStateConverter.fromProto(gameState)
|
||||
): Option[CommandSelection] =
|
||||
flatSelectionForType[ArmTroopsAvailableCommand](availableCommands) { availableCommand =>
|
||||
val provinceId = availableCommand.actingProvinceId
|
||||
val provinceC = gs.provinces(provinceId)
|
||||
val provinceC = gameState.provinces(provinceId)
|
||||
val goldSurplus = provinceGoldSurplus(provinceC)
|
||||
val lowestBattalion = availableCommand.availableBattalions
|
||||
.map(gameState.battalions)
|
||||
@@ -1953,7 +1888,7 @@ object CommandChoiceHelpers {
|
||||
if lowestBattalion.armament >= provinceInfrastructure then None
|
||||
else {
|
||||
val costPerPointPerTroop = availableCommand.armamentCosts
|
||||
.find(cost => cost.`type` == lowestBattalion.`type`)
|
||||
.find(cost => cost.`type`.value == lowestBattalion.typeId.value)
|
||||
.get
|
||||
.cost
|
||||
|
||||
@@ -1983,14 +1918,11 @@ object CommandChoiceHelpers {
|
||||
}
|
||||
end if
|
||||
}
|
||||
}
|
||||
|
||||
/** Protoless version */
|
||||
private def provinceFoodSurplus(province: ProvinceT, gs: GameStateC): Int =
|
||||
private def provinceFoodSurplus(province: ProvinceT, gs: GameState): Int =
|
||||
(ProvinceUtils.absoluteFoodSurplus(province, gs) - FoodPerProvinceHeldBack.intValue)
|
||||
.max(0)
|
||||
|
||||
/** Protoless version */
|
||||
private def destinationCloserTo(
|
||||
actingFactionId: FactionId,
|
||||
provinces: Map[ProvinceId, ProvinceT],
|
||||
@@ -2016,39 +1948,38 @@ object CommandChoiceHelpers {
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
organizeCommand: OrganizeTroopsAvailableCommand,
|
||||
province: Province,
|
||||
province: ProvinceT,
|
||||
costs: Map[BattalionTypeId, Double],
|
||||
beastPower: Int
|
||||
): Option[CommandSelection] = {
|
||||
val gs = GameStateConverter.fromProto(gameState)
|
||||
// Helper to convert Scala BattalionTypeIdC to proto BattalionTypeId for map lookup
|
||||
def toProtoTypeId(scalaTypeId: BattalionTypeIdC): BattalionTypeId =
|
||||
BattalionTypeId.fromValue(scalaTypeId.value)
|
||||
|
||||
val topUpCandidates = province.battalionIds
|
||||
.map(gameState.battalions)
|
||||
.filter(batt =>
|
||||
organizeCommand.availableBattalionTypes
|
||||
.exists(tp => tp.typeId == batt.`type` && tp.meetsRequirements)
|
||||
.exists(tp => tp.typeId.value == batt.typeId.value && tp.meetsRequirements)
|
||||
)
|
||||
.filter(b => costs.contains(b.`type`))
|
||||
.filter(b => costs.contains(toProtoTypeId(b.typeId)))
|
||||
|
||||
if topUpCandidates.isEmpty then None
|
||||
else {
|
||||
val neededTroops = topUpCandidates.map(b => (b, beastPower - b.size))
|
||||
val lowestCost = neededTroops.map {
|
||||
case (batt, needed) =>
|
||||
(batt, needed, needed * costs(batt.`type`))
|
||||
(batt, needed, needed * costs(toProtoTypeId(batt.typeId)))
|
||||
}
|
||||
.minBy(_._3)
|
||||
|
||||
Option.when(lowestCost._3 <= province.gold) {
|
||||
// Hire up to at least beastsCount, but if there's surplus gold, hire more.
|
||||
val batt = lowestCost._1
|
||||
val battC = gs.battalions(batt.id)
|
||||
val capacity =
|
||||
BattalionTypeFinder.battalionType(battC.typeId, gs.battalionTypes).capacity
|
||||
BattalionTypeFinder.battalionType(batt.typeId, gameState.battalionTypes).capacity
|
||||
val maxDesired = batt.size +
|
||||
(provinceGoldSurplus(gs.provinces(province.id)).toDouble / costs(
|
||||
batt.`type`
|
||||
)).floor.toInt
|
||||
(provinceGoldSurplus(province).toDouble / costs(toProtoTypeId(batt.typeId))).floor.toInt
|
||||
|
||||
val actualNewSize = Math.max(beastPower, maxDesired).min(capacity)
|
||||
|
||||
@@ -2075,19 +2006,17 @@ object CommandChoiceHelpers {
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
organizeCommand: AvailableCommand,
|
||||
province: Province,
|
||||
province: ProvinceT,
|
||||
costs: Map[BattalionTypeId, Double],
|
||||
beastPower: Int
|
||||
): Option[CommandSelection] = {
|
||||
val gs = GameStateConverter.fromProto(gameState)
|
||||
|
||||
val lowestCost = costs.minBy(_._2)
|
||||
val costForMinimalBattalion = (lowestCost._2 * beastPower).ceil.toInt
|
||||
Option.when(costForMinimalBattalion <= province.gold) {
|
||||
val goldSurplus = provinceGoldSurplus(gs.provinces(province.id))
|
||||
val goldSurplus = provinceGoldSurplus(province)
|
||||
val protolessTypeId = BattalionTypeIdC.fromInt(lowestCost._1.value)
|
||||
val battalionTypeC =
|
||||
BattalionTypeFinder.battalionType(protolessTypeId, gs.battalionTypes)
|
||||
BattalionTypeFinder.battalionType(protolessTypeId, gameState.battalionTypes)
|
||||
val couldAfford = (goldSurplus / lowestCost._2).floor.toInt
|
||||
.min(battalionTypeC.capacity)
|
||||
val countToBuy = couldAfford.max(beastPower).min(battalionTypeC.capacity)
|
||||
|
||||
+1
-1
@@ -4,8 +4,8 @@ import scala.annotation.tailrec
|
||||
|
||||
import net.eagle0.common.{FunctionalRandom, RandomState}
|
||||
import net.eagle0.eagle.api.available_command.AvailableCommand
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.library.util.CommandSelection
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.FactionId
|
||||
|
||||
@FunctionalInterface
|
||||
|
||||
+10
-14
@@ -2,7 +2,6 @@ package net.eagle0.eagle.library.util.command_choice_helpers
|
||||
|
||||
import net.eagle0.common.{FunctionalRandom, RandomState}
|
||||
import net.eagle0.eagle.api.available_command.AvailableCommand
|
||||
import net.eagle0.eagle.internal.game_state.GameState as GameStateProto
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.quest_command_selectors.{
|
||||
AllianceQuestCommandChooser,
|
||||
AlmsAcrossRealmQuestCommandChooser,
|
||||
@@ -16,10 +15,8 @@ import net.eagle0.eagle.library.util.command_choice_helpers.quest_command_select
|
||||
TruceWithFactionQuestCommandChooser,
|
||||
UnaffiliatedHeroWithQuest
|
||||
}
|
||||
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
|
||||
import net.eagle0.eagle.library.util.province.ProvinceUtils
|
||||
import net.eagle0.eagle.library.util.CommandSelection
|
||||
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
|
||||
import net.eagle0.eagle.model.proto_converters.QuestConverter
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.FactionId
|
||||
|
||||
@@ -56,7 +53,7 @@ object FulfillQuestsCommandSelector {
|
||||
)
|
||||
}
|
||||
|
||||
/** Protoless version - takes native GameState and uhsWithQuests directly */
|
||||
/** Takes native GameState and uhsWithQuests directly */
|
||||
def chosenFulfillEasyQuestsCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameState,
|
||||
@@ -73,31 +70,30 @@ object FulfillQuestsCommandSelector {
|
||||
functionalRandom = functionalRandom
|
||||
)
|
||||
|
||||
/** Proto version - converts to native and extracts uhsWithQuests */
|
||||
/** Computes uhsWithQuests from native GameState */
|
||||
def chosenFulfillEasyQuestsCommand(
|
||||
actingFactionId: FactionId,
|
||||
gameState: GameStateProto,
|
||||
gameState: GameState,
|
||||
availableCommands: Vector[AvailableCommand],
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[Option[CommandSelection]] = {
|
||||
val myProvinces = gameState.provinces.values
|
||||
.filter(_.rulingFactionId.contains(actingFactionId))
|
||||
.filter(province => LegacyProvinceUtils.ruledByFactionLeader(province, gameState))
|
||||
.filter(province => ProvinceUtils.ruledByFactionLeader(province, gameState.factions.values.toVector))
|
||||
|
||||
val uhsWithQuests = (for {
|
||||
province <- myProvinces
|
||||
uh <- province.unaffiliatedHeroes
|
||||
recruitmentInfo <- uh.recruitmentInfo
|
||||
quest <- recruitmentInfo.quest
|
||||
province <- myProvinces
|
||||
uh <- province.unaffiliatedHeroes
|
||||
quest <- uh.quest
|
||||
} yield UnaffiliatedHeroWithQuest(
|
||||
province.id,
|
||||
uh.heroId,
|
||||
QuestConverter.fromProto(quest)
|
||||
quest
|
||||
)).toVector
|
||||
|
||||
chosenFulfillEasyQuestsCommand(
|
||||
actingFactionId = actingFactionId,
|
||||
gameState = GameStateConverter.fromProto(gameState),
|
||||
gameState = gameState,
|
||||
availableCommands = availableCommands,
|
||||
uhsWithQuests = uhsWithQuests,
|
||||
functionalRandom = functionalRandom
|
||||
|
||||
@@ -7,7 +7,11 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle:__subpackages__",
|
||||
"//src/test/scala/net/eagle0/eagle:__subpackages__",
|
||||
],
|
||||
exports = [
|
||||
"//src/main/protobuf/net/eagle0/common:hostility_scala_proto",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/common:hostility_scala_proto",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:min_support_for_taxes",
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:prestige_per_supported_province",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package net.eagle0.eagle.library.util.faction_utils
|
||||
|
||||
import net.eagle0.common.hostility.Hostility
|
||||
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
|
||||
import net.eagle0.eagle.library.settings.{MinSupportForTaxes, PrestigePerSupportedProvince}
|
||||
import net.eagle0.eagle.model.state.faction.{FactionRelationship, FactionT}
|
||||
@@ -76,6 +77,21 @@ object FactionUtils {
|
||||
): Boolean =
|
||||
fid1 != fid2 && !hasTruceOrAlliance(fid1, fid2, factions)
|
||||
|
||||
def factionsAreMutuallyAllied(
|
||||
fids: Vector[FactionId],
|
||||
factions: Vector[FactionT]
|
||||
): Boolean =
|
||||
fids.forall(fid1 => fids.forall(fid2 => !factionsAreHostile(fid1, fid2, factions)))
|
||||
|
||||
def hostilityStatus(
|
||||
fid1: FactionId,
|
||||
fid2: FactionId,
|
||||
factions: Vector[FactionT]
|
||||
): Hostility =
|
||||
if fid1 == fid2 then Hostility.SELF_HOSTILITY
|
||||
else if factionsAreHostile(fid1, fid2, factions) then Hostility.ENEMY_HOSTILITY
|
||||
else Hostility.ALLIED_HOSTILITY
|
||||
|
||||
private def incomingHids(province: ProvinceT): Vector[HeroId] =
|
||||
province.incomingArmies
|
||||
.map(_.army)
|
||||
@@ -136,6 +152,30 @@ object FactionUtils {
|
||||
def provinceCount(factionId: FactionId, provinces: Iterable[ProvinceT]): Int =
|
||||
provinces.count(_.rulingFactionId.contains(factionId))
|
||||
|
||||
def hostileNeighbors(
|
||||
province: ProvinceT,
|
||||
fid: FactionId,
|
||||
allProvinces: Map[ProvinceId, ProvinceT],
|
||||
factions: Vector[FactionT]
|
||||
): Vector[ProvinceT] =
|
||||
province.neighbors
|
||||
.map(_.provinceId)
|
||||
.map(allProvinces)
|
||||
.filter(_.rulingFactionId.isDefined)
|
||||
.filterNot(_.rulingFactionId.contains(fid))
|
||||
.filterNot(neighbor => hasTruceOrAlliance(fid, neighbor.rulingFactionId.get, factions))
|
||||
.toVector
|
||||
|
||||
def neutralNeighbors(
|
||||
province: ProvinceT,
|
||||
allProvinces: Map[ProvinceId, ProvinceT]
|
||||
): Vector[ProvinceT] =
|
||||
province.neighbors
|
||||
.map(_.provinceId)
|
||||
.map(allProvinces)
|
||||
.filterNot(_.rulingFactionId.isDefined)
|
||||
.toVector
|
||||
|
||||
def ownedNeighbors(
|
||||
provinces: Map[ProvinceId, ProvinceT],
|
||||
fid: FactionId
|
||||
|
||||
@@ -9,6 +9,9 @@ scala_library(
|
||||
],
|
||||
exports = [
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/province",
|
||||
"//src/main/scala/net/eagle0/eagle/model/view/stat_with_condition",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
@@ -17,8 +20,11 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/settings:minimum_wisdom_for_start_fire",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/faction",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/hero",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/hero:gender",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/province",
|
||||
"//src/main/scala/net/eagle0/eagle/model/view/stat_with_condition",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package net.eagle0.eagle.library.util.hero
|
||||
|
||||
import scala.annotation.tailrec
|
||||
|
||||
import net.eagle0.eagle.{FactionId, HeroId, ProvinceId}
|
||||
import net.eagle0.eagle.library.settings.{
|
||||
MinimumAgilityForArchery,
|
||||
MinimumAgilityForStartFire,
|
||||
@@ -9,9 +10,11 @@ import net.eagle0.eagle.library.settings.{
|
||||
}
|
||||
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
|
||||
import net.eagle0.eagle.model.state.faction.FactionT
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.model.state.hero.HeroT
|
||||
import net.eagle0.eagle.model.state.hero.Profession.{Mage, NoProfession}
|
||||
import net.eagle0.eagle.HeroId
|
||||
import net.eagle0.eagle.model.state.province.ProvinceT
|
||||
import net.eagle0.eagle.model.view.stat_with_condition.{Condition, StatWithCondition}
|
||||
|
||||
object HeroUtils {
|
||||
|
||||
@@ -102,4 +105,66 @@ object HeroUtils {
|
||||
hero.profession == Mage ||
|
||||
(hero.agility >= MinimumAgilityForStartFire.doubleValue &&
|
||||
hero.wisdom >= MinimumWisdomForStartFire.doubleValue)
|
||||
|
||||
def faction(hero: HeroT, factions: Iterable[FactionT]): Option[FactionT] =
|
||||
hero.factionId.flatMap(fid => factions.find(_.id == fid))
|
||||
|
||||
def loyaltyAsStatWithCondition(
|
||||
hero: HeroT,
|
||||
factions: Vector[FactionT]
|
||||
): Option[StatWithCondition] =
|
||||
Option.when(!FactionUtils.isFactionLeader(hero.id, factions))(
|
||||
scWithRanges(
|
||||
hero.loyalty.floor.toInt,
|
||||
minMedium = 50,
|
||||
minHigh = 80
|
||||
)
|
||||
)
|
||||
|
||||
def vigorAsStatWithCondition(hero: HeroT): Option[StatWithCondition] =
|
||||
Some(
|
||||
scWithRanges(
|
||||
hero.vigor.floor.toInt,
|
||||
minMedium = 40,
|
||||
minHigh = 70
|
||||
)
|
||||
)
|
||||
|
||||
private def scWithRanges(
|
||||
value: Int,
|
||||
minMedium: Int,
|
||||
minHigh: Int
|
||||
): StatWithCondition =
|
||||
StatWithCondition(
|
||||
stat = value,
|
||||
condition =
|
||||
if value < minMedium then Condition.Low
|
||||
else if value < minHigh then Condition.Medium
|
||||
else Condition.High
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns provinces a faction leader can act on. For vassals, returns just the one province; for faction leaders,
|
||||
* returns all owned provinces that don't contain other faction leaders.
|
||||
*/
|
||||
def consideredProvinces(
|
||||
gameState: GameState,
|
||||
factionId: FactionId,
|
||||
provinceId: ProvinceId
|
||||
): Vector[ProvinceT] = {
|
||||
val factions = gameState.factions.values.toVector
|
||||
val province = gameState.provinces(provinceId)
|
||||
if province.rulingHeroId.exists(hid => FactionUtils.isFactionHead(hid, factions)) then
|
||||
val factionHeadId = factions.find(_.id == factionId).map(_.factionHeadId)
|
||||
val leadersBesidesHead = FactionUtils.leadersBesidesHead(factions.find(_.id == factionId).get)
|
||||
gameState.provinces.values
|
||||
.filter(_.rulingFactionId.contains(factionId))
|
||||
.filterNot(p =>
|
||||
p.rulingFactionHeroIds.exists(leadersBesidesHead.contains) &&
|
||||
!p.rulingFactionHeroIds.exists(factionHeadId.contains)
|
||||
)
|
||||
.toVector
|
||||
.sortBy(p => if p.id == provinceId then 0 else p.id)
|
||||
else Vector(province)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,9 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle:__subpackages__",
|
||||
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
|
||||
],
|
||||
exports = [
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:army_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto",
|
||||
@@ -123,7 +126,9 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:faction_view_filter",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:hero_view_filter",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:province_view_filter",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view/province:province_view",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
package net.eagle0.eagle.library.util.view_filters
|
||||
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
|
||||
import net.eagle0.eagle.model.proto_converters.view.province.ProvinceViewConverter
|
||||
import net.eagle0.eagle.model.state.game_state.GameState as ScalaGameState
|
||||
import net.eagle0.eagle.views.faction_view.FactionView
|
||||
import net.eagle0.eagle.views.game_state_view.GameStateView
|
||||
import net.eagle0.eagle.FactionId
|
||||
|
||||
object GameStateViewFilter {
|
||||
|
||||
/**
|
||||
* Scala GameState overload - converts to proto internally for sub-filters that still need it. This allows callers to
|
||||
* pass Scala GameState directly, avoiding conversion at call site.
|
||||
*/
|
||||
def filteredGameState(
|
||||
gs: ScalaGameState,
|
||||
factionId: Option[FactionId]
|
||||
): GameStateView =
|
||||
// TODO: Update sub-filters (HeroViewFilter, FactionViewFilter, etc.) to accept Scala types,
|
||||
// then remove this internal conversion
|
||||
filteredGameState(GameStateConverter.toProto(gs), factionId)
|
||||
|
||||
def filteredGameState(
|
||||
gs: GameState,
|
||||
factionId: Option[FactionId]
|
||||
|
||||
@@ -10,6 +10,7 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/availability:__pkg__",
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/impl:__subpackages__",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:__subpackages__",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:__pkg__",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters:__subpackages__",
|
||||
"//src/main/scala/net/eagle0/eagle/service:__subpackages__",
|
||||
"//src/test/scala/net/eagle0/eagle/library:__pkg__",
|
||||
|
||||
@@ -4,9 +4,13 @@ scala_library(
|
||||
name = "battalion",
|
||||
srcs = ["BattalionViewConverter.scala"],
|
||||
visibility = [
|
||||
"//src/main/scala/net/eagle0/eagle/ai:__pkg__",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero:__pkg__",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view:__subpackages__",
|
||||
],
|
||||
exports = [
|
||||
"//src/main/scala/net/eagle0/eagle/model/view/battalion",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/views:battalion_view_scala_proto",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
|
||||
@@ -13,6 +13,7 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result:__subpackages__",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters:__pkg__",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/shardok_battle:__pkg__",
|
||||
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
|
||||
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
|
||||
"//src/test/scala/net/eagle0/eagle/library/actions:__subpackages__",
|
||||
"//src/test/scala/net/eagle0/eagle/model/state:__subpackages__",
|
||||
@@ -57,6 +58,7 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result:__subpackages__",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters:__pkg__",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state:__pkg__",
|
||||
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
|
||||
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
|
||||
@@ -18,10 +18,13 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state:__pkg__",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/hero:__pkg__",
|
||||
"//src/main/scala/net/eagle0/eagle/shardok_interface:__pkg__",
|
||||
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
|
||||
"//src/test/scala/net/eagle0/eagle/library:__subpackages__",
|
||||
"//src/test/scala/net/eagle0/eagle/library/util:__subpackages__",
|
||||
],
|
||||
exports = ["//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id"],
|
||||
exports = [
|
||||
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state:battalion_type_id",
|
||||
|
||||
@@ -7,6 +7,7 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions:__subpackages__",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters:__pkg__",
|
||||
"//src/main/scala/net/eagle0/eagle/shardok_interface:__pkg__",
|
||||
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
|
||||
"//src/test/scala/net/eagle0/eagle/library/actions:__subpackages__",
|
||||
"//src/test/scala/net/eagle0/eagle/library/util:__subpackages__",
|
||||
],
|
||||
|
||||
@@ -4,7 +4,10 @@ scala_library(
|
||||
name = "battalion",
|
||||
srcs = ["BattalionView.scala"],
|
||||
visibility = [
|
||||
"//src/main/scala/net/eagle0/eagle/ai:__pkg__",
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/impl/command:__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/library/util/view_filters:__pkg__",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/hero:__pkg__",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/view:__subpackages__",
|
||||
|
||||
@@ -6,124 +6,175 @@ import scala.concurrent.Future
|
||||
|
||||
import net.eagle0.eagle.api.auth.auth.*
|
||||
import net.eagle0.eagle.api.auth.auth.AuthGrpc.Auth
|
||||
import net.eagle0.eagle.auth.{JwtService, OAuthService, ProviderUserInfo, UserService}
|
||||
import net.eagle0.eagle.auth.{
|
||||
JwtService,
|
||||
OAuthExpired,
|
||||
OAuthFailure,
|
||||
OAuthPending,
|
||||
OAuthService,
|
||||
OAuthSuccess,
|
||||
UserService
|
||||
}
|
||||
import net.eagle0.eagle.internal.user.user.User
|
||||
|
||||
/** gRPC service implementation for OAuth authentication */
|
||||
/**
|
||||
* gRPC service implementation for OAuth authentication.
|
||||
*
|
||||
* When externalAuthClient is provided, OAuth methods (getOAuthUrl, checkOAuthStatus, refreshToken) are forwarded to the
|
||||
* external Go auth service. Other methods (setDisplayName, getCurrentUser, logout) are always handled locally.
|
||||
*/
|
||||
class AuthServiceImpl(
|
||||
oauthService: OAuthService,
|
||||
userService: UserService,
|
||||
jwtService: JwtService
|
||||
jwtService: JwtService,
|
||||
externalAuthClient: Option[ExternalAuthClient] = None
|
||||
) extends Auth {
|
||||
|
||||
override def getOAuthUrl(request: GetOAuthUrlRequest): Future[GetOAuthUrlResponse] =
|
||||
Future {
|
||||
val (url, state) = oauthService.getAuthUrl(request.provider, request.redirectUri)
|
||||
GetOAuthUrlResponse(authUrl = url, state = state)
|
||||
externalAuthClient match {
|
||||
case Some(client) => client.getOAuthUrl(request)
|
||||
case None =>
|
||||
Future {
|
||||
val (url, state) = oauthService.getAuthUrl(request.provider)
|
||||
GetOAuthUrlResponse(authUrl = url, state = state)
|
||||
}
|
||||
}
|
||||
|
||||
override def exchangeCode(request: ExchangeCodeRequest): Future[ExchangeCodeResponse] =
|
||||
override def checkOAuthStatus(
|
||||
request: CheckOAuthStatusRequest
|
||||
): Future[CheckOAuthStatusResponse] =
|
||||
externalAuthClient match {
|
||||
case Some(client) => client.checkOAuthStatus(request)
|
||||
case None => checkOAuthStatusLocal(request)
|
||||
}
|
||||
|
||||
private def checkOAuthStatusLocal(
|
||||
request: CheckOAuthStatusRequest
|
||||
): Future[CheckOAuthStatusResponse] = Future {
|
||||
oauthService.checkStatus(request.state) match {
|
||||
case OAuthPending =>
|
||||
CheckOAuthStatusResponse(status = OAuthStatus.OAUTH_STATUS_PENDING)
|
||||
|
||||
case OAuthExpired =>
|
||||
CheckOAuthStatusResponse(status = OAuthStatus.OAUTH_STATUS_EXPIRED)
|
||||
|
||||
case OAuthFailure(error) =>
|
||||
CheckOAuthStatusResponse(
|
||||
status = OAuthStatus.OAUTH_STATUS_FAILED,
|
||||
errorMessage = error
|
||||
)
|
||||
|
||||
case OAuthSuccess(providerInfo, provider) =>
|
||||
// Find or create user
|
||||
val providerName = provider match {
|
||||
case OAuthProvider.OAUTH_PROVIDER_DISCORD => "discord"
|
||||
case OAuthProvider.OAUTH_PROVIDER_GOOGLE => "google"
|
||||
case _ => "unknown"
|
||||
}
|
||||
|
||||
val user = userService.findOrCreateUser(
|
||||
provider = providerName,
|
||||
providerUserId = providerInfo.id,
|
||||
email = providerInfo.email,
|
||||
avatarUrl = providerInfo.avatarUrl
|
||||
)
|
||||
|
||||
val isNewUser = user.displayName.isEmpty
|
||||
|
||||
// Create JWT tokens
|
||||
val accessToken = jwtService.createAccessToken(
|
||||
userId = user.userId,
|
||||
displayName = user.displayName,
|
||||
isAdmin = user.isAdmin
|
||||
)
|
||||
|
||||
val (refreshToken, _) = jwtService.createRefreshToken(user.userId)
|
||||
|
||||
// Access token expires in 7 days (matches JwtServiceImpl.accessTokenDuration)
|
||||
val accessTokenExpiresAt =
|
||||
java.time.Instant.now().plus(java.time.Duration.ofDays(7)).getEpochSecond
|
||||
|
||||
CheckOAuthStatusResponse(
|
||||
status = OAuthStatus.OAUTH_STATUS_SUCCESS,
|
||||
accessToken = accessToken,
|
||||
refreshToken = refreshToken,
|
||||
expiresAt = accessTokenExpiresAt,
|
||||
user = Some(toUserInfo(user, provider)),
|
||||
isNewUser = isNewUser
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override def setDisplayName(
|
||||
request: SetDisplayNameRequest
|
||||
): Future[SetDisplayNameResponse] = {
|
||||
// Capture userId on gRPC thread before Future runs on executor thread
|
||||
val userId = AuthorizationUtils.userId
|
||||
Future {
|
||||
oauthService.exchangeCode(
|
||||
provider = request.provider,
|
||||
code = request.authorizationCode,
|
||||
state = request.state,
|
||||
redirectUri = request.redirectUri
|
||||
) match {
|
||||
userService.setDisplayName(userId, request.displayName) match {
|
||||
case Left(error) =>
|
||||
throw new io.grpc.StatusRuntimeException(
|
||||
io.grpc.Status.UNAUTHENTICATED.withDescription(error)
|
||||
SetDisplayNameResponse(
|
||||
success = false,
|
||||
errorMessage = error,
|
||||
user = None,
|
||||
accessToken = ""
|
||||
)
|
||||
|
||||
case Right(providerInfo) =>
|
||||
// Find or create user
|
||||
val providerName = request.provider match {
|
||||
case OAuthProvider.OAUTH_PROVIDER_DISCORD => "discord"
|
||||
case OAuthProvider.OAUTH_PROVIDER_GOOGLE => "google"
|
||||
case _ => "unknown"
|
||||
}
|
||||
|
||||
val user = userService.findOrCreateUser(
|
||||
provider = providerName,
|
||||
providerUserId = providerInfo.id,
|
||||
email = providerInfo.email,
|
||||
avatarUrl = providerInfo.avatarUrl
|
||||
)
|
||||
|
||||
val isNewUser = user.displayName.isEmpty
|
||||
|
||||
// Create JWT tokens
|
||||
val accessToken = jwtService.createAccessToken(
|
||||
case Right(user) =>
|
||||
// Create a new access token with the updated displayName
|
||||
val newToken = jwtService.createAccessToken(
|
||||
userId = user.userId,
|
||||
displayName = user.displayName,
|
||||
isAdmin = user.isAdmin
|
||||
)
|
||||
|
||||
val (refreshToken, refreshInfo) = jwtService.createRefreshToken(user.userId)
|
||||
|
||||
ExchangeCodeResponse(
|
||||
accessToken = accessToken,
|
||||
refreshToken = refreshToken,
|
||||
expiresAt = refreshInfo.expiresAt.toEpochMilli / 1000,
|
||||
user = Some(toUserInfo(user, request.provider)),
|
||||
isNewUser = isNewUser
|
||||
SetDisplayNameResponse(
|
||||
success = true,
|
||||
errorMessage = "",
|
||||
user = Some(toUserInfo(user, getProviderForUser(user))),
|
||||
accessToken = newToken
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override def setDisplayName(
|
||||
request: SetDisplayNameRequest
|
||||
): Future[SetDisplayNameResponse] = Future {
|
||||
val userId = AuthorizationUtils.userId
|
||||
|
||||
userService.setDisplayName(userId, request.displayName) match {
|
||||
case Left(error) =>
|
||||
SetDisplayNameResponse(
|
||||
success = false,
|
||||
errorMessage = error,
|
||||
user = None
|
||||
)
|
||||
|
||||
case Right(user) =>
|
||||
SetDisplayNameResponse(
|
||||
success = true,
|
||||
errorMessage = "",
|
||||
user = Some(toUserInfo(user, getProviderForUser(user)))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override def refreshToken(
|
||||
request: RefreshTokenRequest
|
||||
): Future[RefreshTokenResponse] = Future {
|
||||
// For now, just validate the refresh token format and issue a new access token
|
||||
// Full refresh token validation would check against stored tokens
|
||||
val tokenHash = jwtService.hashRefreshToken(request.refreshToken)
|
||||
): Future[RefreshTokenResponse] =
|
||||
externalAuthClient match {
|
||||
case Some(client) => client.refreshToken(request)
|
||||
case None =>
|
||||
Future {
|
||||
// For now, just validate the refresh token format and issue a new access token
|
||||
// Full refresh token validation would check against stored tokens
|
||||
val tokenHash = jwtService.hashRefreshToken(request.refreshToken)
|
||||
|
||||
// TODO: Look up user from refresh token database
|
||||
// For now, extract user ID from the JWT-like structure if available
|
||||
// This is a simplified implementation - production would validate against stored tokens
|
||||
// TODO: Look up user from refresh token database
|
||||
// For now, extract user ID from the JWT-like structure if available
|
||||
// This is a simplified implementation - production would validate against stored tokens
|
||||
|
||||
throw new io.grpc.StatusRuntimeException(
|
||||
io.grpc.Status.UNIMPLEMENTED.withDescription("Refresh token not yet implemented")
|
||||
)
|
||||
}
|
||||
throw new io.grpc.StatusRuntimeException(
|
||||
io.grpc.Status.UNIMPLEMENTED.withDescription("Refresh token not yet implemented")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override def getCurrentUser(
|
||||
request: GetCurrentUserRequest
|
||||
): Future[GetCurrentUserResponse] = Future {
|
||||
): Future[GetCurrentUserResponse] = {
|
||||
// Capture userId on gRPC thread before Future runs on executor thread
|
||||
val userId = AuthorizationUtils.userId
|
||||
Future {
|
||||
userService.findByUserId(userId) match {
|
||||
case Some(user) =>
|
||||
GetCurrentUserResponse(
|
||||
user = Some(toUserInfo(user, getProviderForUser(user)))
|
||||
)
|
||||
|
||||
userService.findByUserId(userId) match {
|
||||
case Some(user) =>
|
||||
GetCurrentUserResponse(
|
||||
user = Some(toUserInfo(user, getProviderForUser(user)))
|
||||
)
|
||||
|
||||
case None =>
|
||||
throw new io.grpc.StatusRuntimeException(
|
||||
io.grpc.Status.NOT_FOUND.withDescription("User not found")
|
||||
)
|
||||
case None =>
|
||||
throw new io.grpc.StatusRuntimeException(
|
||||
io.grpc.Status.NOT_FOUND.withDescription("User not found")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ class AuthorizationInterceptor(jwtService: Option[JwtService] = None) extends Se
|
||||
// Endpoints that don't require authentication
|
||||
private val publicEndpoints = Set(
|
||||
"net.eagle0.eagle.api.auth.Auth/GetOAuthUrl",
|
||||
"net.eagle0.eagle.api.auth.Auth/ExchangeCode",
|
||||
"net.eagle0.eagle.api.auth.Auth/CheckOAuthStatus",
|
||||
"net.eagle0.eagle.api.auth.Auth/RefreshToken"
|
||||
)
|
||||
|
||||
|
||||
@@ -42,4 +42,6 @@ object AuthorizationUtils {
|
||||
.withValue(userIdCtxKey, userId)
|
||||
.withValue(displayNameCtxKey, displayName)
|
||||
.withValue(isAdminCtxKey, isAdmin)
|
||||
// Also set legacy userName for backwards compatibility with game management code
|
||||
.withValue(userNameCtxKey, displayName)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ scala_library(
|
||||
],
|
||||
deps = [
|
||||
":authorization_utils",
|
||||
":external_auth_client",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:user_scala_proto",
|
||||
"//src/main/scala/net/eagle0/eagle/auth:jwt_service",
|
||||
@@ -18,6 +19,45 @@ scala_library(
|
||||
],
|
||||
)
|
||||
|
||||
scala_library(
|
||||
name = "external_auth_client",
|
||||
srcs = ["ExternalAuthClient.scala"],
|
||||
visibility = [
|
||||
"//src/main/scala/net/eagle0/eagle:__pkg__",
|
||||
"//src/test/scala/net/eagle0/eagle:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
|
||||
"@maven//:io_grpc_grpc_api",
|
||||
],
|
||||
)
|
||||
|
||||
scala_library(
|
||||
name = "internal_user_service",
|
||||
srcs = ["InternalUserServiceImpl.scala"],
|
||||
visibility = [
|
||||
"//src/main/scala/net/eagle0/eagle:__pkg__",
|
||||
"//src/test/scala/net/eagle0/eagle:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:auth_internal_scala_grpc",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:user_scala_proto",
|
||||
"//src/main/scala/net/eagle0/eagle/auth:user_service",
|
||||
],
|
||||
)
|
||||
|
||||
scala_library(
|
||||
name = "oauth_http_handler",
|
||||
srcs = ["OAuthHttpHandler.scala"],
|
||||
visibility = [
|
||||
"//src/main/scala/net/eagle0/eagle:__pkg__",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/scala/net/eagle0/common:simple_timed_logger",
|
||||
"//src/main/scala/net/eagle0/eagle/auth:oauth_service",
|
||||
],
|
||||
)
|
||||
|
||||
scala_library(
|
||||
name = "service",
|
||||
srcs = [
|
||||
@@ -52,6 +92,7 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/service/new_game_creation:game_parameters_utils",
|
||||
"//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",
|
||||
@@ -411,6 +452,8 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_update",
|
||||
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_update_receiver",
|
||||
"//src/main/scala/net/eagle0/eagle/shardok_interface:shardok_interface_grpc_client",
|
||||
"@grpc-java//api",
|
||||
"@grpc-java//core",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package net.eagle0.eagle.service
|
||||
|
||||
import java.io.{ByteArrayOutputStream, File}
|
||||
import java.nio.file.{Files, Paths}
|
||||
import java.util.zip.{ZipEntry, ZipOutputStream}
|
||||
|
||||
import scala.concurrent.ExecutionContext.Implicits.global
|
||||
import scala.concurrent.Future
|
||||
import scala.util.{Random, Try}
|
||||
import scala.util.{Random, Try, Using}
|
||||
import scala.util.hashing.MurmurHash3
|
||||
|
||||
import com.google.protobuf.ByteString
|
||||
import io.grpc.stub.StreamObserver
|
||||
import io.grpc.StatusRuntimeException
|
||||
import net.eagle0.common.{FunctionalRandom, RandomState, SimpleTimedLogger}
|
||||
@@ -19,6 +24,7 @@ import net.eagle0.eagle.library.settings.MaxSupportedPlayers
|
||||
import net.eagle0.eagle.library.EagleClientException
|
||||
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
|
||||
import net.eagle0.eagle.service.new_game_creation.GameParametersUtils
|
||||
import net.eagle0.eagle.service.persistence.SaveDirectory
|
||||
import net.eagle0.eagle.service.CustomBattleManager.CreateCustomBattleResponse
|
||||
import org.json4s.*
|
||||
import org.json4s.native.Serialization.writePretty
|
||||
@@ -651,60 +657,73 @@ class EagleServiceImpl(
|
||||
gamesManager.gameControllerInfos.get(request.gameId) match {
|
||||
case Some(controllerInfo) =>
|
||||
val history = controllerInfo.controller.engine.history
|
||||
val allResults = history.all
|
||||
val totalCount = history.count
|
||||
|
||||
val startIndex = if request.startIndex > 0 then request.startIndex else 0
|
||||
val limit = if request.limit > 0 then request.limit else allResults.length
|
||||
val limit = request.limit
|
||||
|
||||
implicit val formats: DefaultFormats.type = DefaultFormats
|
||||
val monthNames = Vector(
|
||||
"",
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December"
|
||||
)
|
||||
val entries = allResults.zipWithIndex
|
||||
.drop(startIndex)
|
||||
.take(limit)
|
||||
.map {
|
||||
case (actionWithState, index) =>
|
||||
val ar = actionWithState.actionResult
|
||||
val gs = actionWithState.gameState
|
||||
val actionJson = writePretty(JsonFormat.toJson(ar))
|
||||
val factionName = ar.player
|
||||
.flatMap(fid => gs.factions.get(fid))
|
||||
.map(_.name)
|
||||
.getOrElse("")
|
||||
val gameDate = gs.currentDate
|
||||
.map(d => s"${monthNames(d.month)} ${d.year}")
|
||||
.getOrElse("")
|
||||
GameHistoryEntry(
|
||||
index = index,
|
||||
actionType = ar.`type`.name,
|
||||
roundId = gs.currentRoundId,
|
||||
actingFactionId = ar.player,
|
||||
provinceId = ar.province,
|
||||
summary = s"${ar.`type`.name} action",
|
||||
actionJson = actionJson,
|
||||
factionName = factionName,
|
||||
gameDate = gameDate
|
||||
)
|
||||
}
|
||||
// If limit is 0, return just the count without loading any entries
|
||||
if limit <= 0 then
|
||||
GetGameHistoryResponse(
|
||||
gameId = request.gameId,
|
||||
totalActionCount = totalCount,
|
||||
entries = Seq.empty
|
||||
)
|
||||
else {
|
||||
implicit val formats: DefaultFormats.type = DefaultFormats
|
||||
val monthNames = Vector(
|
||||
"",
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December"
|
||||
)
|
||||
// Use since(startIndex) for efficient partial loading instead of loading all results
|
||||
val entries = history
|
||||
.since(startIndex)
|
||||
.take(limit)
|
||||
.zipWithIndex
|
||||
.map {
|
||||
case (actionWithState, localIndex) =>
|
||||
val index = startIndex + localIndex
|
||||
val ar = actionWithState.actionResult
|
||||
val gs = actionWithState.gameState
|
||||
val actionJson = writePretty(JsonFormat.toJson(ar))
|
||||
val factionName = ar.player
|
||||
.flatMap(fid => gs.factions.get(fid))
|
||||
.map(_.name)
|
||||
.getOrElse("")
|
||||
val gameDate = gs.currentDate
|
||||
.map(d => s"${monthNames(d.month)} ${d.year}")
|
||||
.getOrElse("")
|
||||
GameHistoryEntry(
|
||||
index = index,
|
||||
actionType = ar.`type`.name,
|
||||
roundId = gs.currentRoundId,
|
||||
actingFactionId = ar.player,
|
||||
provinceId = ar.province,
|
||||
summary = s"${ar.`type`.name} action",
|
||||
actionJson = actionJson,
|
||||
factionName = factionName,
|
||||
gameDate = gameDate
|
||||
)
|
||||
}
|
||||
|
||||
GetGameHistoryResponse(
|
||||
gameId = request.gameId,
|
||||
totalActionCount = allResults.length,
|
||||
entries = entries
|
||||
)
|
||||
GetGameHistoryResponse(
|
||||
gameId = request.gameId,
|
||||
totalActionCount = totalCount,
|
||||
entries = entries
|
||||
)
|
||||
}
|
||||
end if
|
||||
|
||||
case None =>
|
||||
GetGameHistoryResponse(
|
||||
@@ -901,6 +920,80 @@ class EagleServiceImpl(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override def importGame(
|
||||
request: ImportGameRequest
|
||||
): Future[ImportGameResponse] = Future {
|
||||
gamesManager.importGame(gameId = request.gameId) match {
|
||||
case Right(()) =>
|
||||
SimpleTimedLogger.printLogger.logLine(
|
||||
s"Imported game ${request.gameId.toHexString}"
|
||||
)
|
||||
ImportGameResponse(result = ImportGameResponse.Result.SUCCESS)
|
||||
case Left(error) =>
|
||||
SimpleTimedLogger.printLogger.logLine(
|
||||
s"Failed to import game ${request.gameId.toHexString}: $error"
|
||||
)
|
||||
val result =
|
||||
if error.contains("already running") then ImportGameResponse.Result.ALREADY_RUNNING
|
||||
else ImportGameResponse.Result.INVALID_SAVE
|
||||
ImportGameResponse(result = result, errorMessage = error)
|
||||
}
|
||||
}
|
||||
|
||||
override def downloadGameSave(
|
||||
request: DownloadGameSaveRequest,
|
||||
responseObserver: StreamObserver[DownloadGameSaveResponse]
|
||||
): Unit = {
|
||||
val gameId = request.gameId
|
||||
val saveDir = new File(SaveDirectory.saveDirectoryForGame(gameId))
|
||||
val chunkSize = 64 * 1024 // 64KB chunks
|
||||
|
||||
try {
|
||||
if !saveDir.exists() || !saveDir.isDirectory then {
|
||||
responseObserver.onError(
|
||||
new EagleClientException(s"Save directory not found for game ${gameId.toHexString}")
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Create zip in memory
|
||||
val baos = new ByteArrayOutputStream()
|
||||
val zipOut = new ZipOutputStream(baos)
|
||||
val saveFiles = saveDir.listFiles().filter(_.isFile)
|
||||
|
||||
for file <- saveFiles do {
|
||||
zipOut.putNextEntry(new ZipEntry(file.getName))
|
||||
val fileBytes = Files.readAllBytes(file.toPath)
|
||||
zipOut.write(fileBytes)
|
||||
zipOut.closeEntry()
|
||||
}
|
||||
zipOut.close()
|
||||
|
||||
// Stream the zip in chunks
|
||||
val zipBytes = baos.toByteArray
|
||||
var offset = 0
|
||||
while offset < zipBytes.length do {
|
||||
val end = math.min(offset + chunkSize, zipBytes.length)
|
||||
val chunk = java.util.Arrays.copyOfRange(zipBytes, offset, end)
|
||||
responseObserver.onNext(
|
||||
DownloadGameSaveResponse(chunk = ByteString.copyFrom(chunk))
|
||||
)
|
||||
offset = end
|
||||
}
|
||||
|
||||
responseObserver.onCompleted()
|
||||
SimpleTimedLogger.printLogger.logLine(
|
||||
s"Downloaded game save ${gameId.toHexString} (${zipBytes.length} bytes)"
|
||||
)
|
||||
} catch {
|
||||
case e: Exception =>
|
||||
SimpleTimedLogger.printLogger.logLine(
|
||||
s"Failed to download game save ${gameId.toHexString}: ${e.getMessage}"
|
||||
)
|
||||
responseObserver.onError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object EagleServiceImpl {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Copyright 2025 Dan Crosby
|
||||
package net.eagle0.eagle.service
|
||||
|
||||
import scala.concurrent.{ExecutionContext, Future}
|
||||
|
||||
import io.grpc.{ManagedChannel, ManagedChannelBuilder}
|
||||
import net.eagle0.eagle.api.auth.auth.*
|
||||
import net.eagle0.eagle.api.auth.auth.AuthGrpc.AuthStub
|
||||
|
||||
/** Client for forwarding OAuth requests to an external Go auth service */
|
||||
class ExternalAuthClient(channel: ManagedChannel)(implicit ec: ExecutionContext) {
|
||||
|
||||
private val stub: AuthStub = AuthGrpc.stub(channel)
|
||||
|
||||
def getOAuthUrl(request: GetOAuthUrlRequest): Future[GetOAuthUrlResponse] =
|
||||
stub.getOAuthUrl(request)
|
||||
|
||||
def checkOAuthStatus(request: CheckOAuthStatusRequest): Future[CheckOAuthStatusResponse] =
|
||||
stub.checkOAuthStatus(request)
|
||||
|
||||
def refreshToken(request: RefreshTokenRequest): Future[RefreshTokenResponse] =
|
||||
stub.refreshToken(request)
|
||||
|
||||
def shutdown(): Unit = { val _ = channel.shutdown() }
|
||||
}
|
||||
|
||||
object ExternalAuthClient {
|
||||
|
||||
/** Create an external auth client from an address string like "localhost:40033" */
|
||||
def apply(address: String)(implicit ec: ExecutionContext): ExternalAuthClient = {
|
||||
val parts = address.split(":")
|
||||
val host = parts(0)
|
||||
val port = parts(1).toInt
|
||||
val channel = ManagedChannelBuilder
|
||||
.forAddress(host, port)
|
||||
.usePlaintext()
|
||||
.build()
|
||||
|
||||
new ExternalAuthClient(channel)
|
||||
}
|
||||
}
|
||||
@@ -1127,4 +1127,30 @@ class GamesManager(
|
||||
}
|
||||
end if
|
||||
}
|
||||
|
||||
def importGame(gameId: GameId): Either[String, Unit] = this.synchronized {
|
||||
if gameControllerInfos.contains(gameId) then Left("Game is already running")
|
||||
else {
|
||||
val gamePersister = gamePersisterCreation.persisterForGame(gameId)
|
||||
|
||||
// Verify save exists by trying to load history
|
||||
GamesManager.gameHistoryFromFiles(gameId, gamePersister) match {
|
||||
case None =>
|
||||
Left("No valid save found for this game ID")
|
||||
case Some(history) =>
|
||||
// Create controller with no human players initially
|
||||
val controller = GamesManager.gameController(
|
||||
gameId = gameId,
|
||||
gamePersister = gamePersister,
|
||||
history = history,
|
||||
userToFid = Map.empty, // No players assigned - admin can add later
|
||||
pregeneratedHeroes = GameParametersUtils.allPregeneratedHeroes
|
||||
)
|
||||
|
||||
addController(controller, isAiGame = true, maxPlayers = 2)
|
||||
save()
|
||||
Right(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user