Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 4.5 5331bdedb1 Add hold-shift-to-pause for autoscroll
Hold either Shift key to pause auto-scrolling, letting the user
read at their own pace. Releasing Shift resumes from current position.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 17:00:32 -08:00
adminandClaude Opus 4.5 364b0978d8 Re-enable settings panel in Gameplay scene
Accidentally disabled in previous layout adjustments.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 15:29:04 -08:00
294 changed files with 6483 additions and 16290 deletions
+2 -2
View File
@@ -26,8 +26,8 @@ common --host_cxxopt="--std=c++23"
common --javacopt="-Xlint:-options"
# suppress warnings due to https://developer.apple.com/forums/thread/733317
# Use host_linkopt for macOS-specific flags to avoid passing them to Linux cross-compilation
common:macos --host_linkopt=-Wl,-no_warn_duplicate_libraries
common --linkopt=-Wl
common:macos --linkopt=-Wl,-no_warn_duplicate_libraries
# Fix Xcode version caching issue - avoids need for `bazel clean --expunge` after Xcode updates
common:macos --repo_env=DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
-3
View File
@@ -6,7 +6,4 @@
*.bytes filter=lfs diff=lfs merge=lfs -text
*.psd filter=lfs diff=lfs merge=lfs -text
*.ttf filter=lfs diff=lfs merge=lfs -text
# Exclude pre-existing font files that were committed as blobs (not LFS pointers)
src/main/csharp/**/GUI[[:space:]]Pro[[:space:]]Kit*/**/*.ttf !filter !diff !merge
src/main/csharp/**/Modern[[:space:]]UI[[:space:]]Pack/**/*.ttf !filter !diff !merge
*.herodata filter=lfs diff=lfs merge=lfs -text
-66
View File
@@ -1,66 +0,0 @@
name: Build Linux Sysroot
on:
workflow_dispatch:
inputs:
version:
description: 'Sysroot version (e.g., v2, v3)'
required: true
default: 'v2'
type: string
permissions:
contents: read
jobs:
build-sysroot:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build sysroot
run: ./tools/sysroot/build_sysroot.sh
- name: Upload sysroot artifact
uses: actions/upload-artifact@v4
with:
name: ubuntu-noble-sysroot
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 (using eagle0-windows bucket, same as other workflows)
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 \
--acl public-read
# Upload sha256 file
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256 \
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.sha256 \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
echo ""
echo "=== Sysroot uploaded ==="
echo "URL: https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz"
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)"
echo ""
echo "Update MODULE.bazel with:"
echo "sysroot("
echo " name = \"linux_sysroot\","
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)\","
echo " urls = [\"https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
echo ")"
-456
View File
@@ -1,456 +0,0 @@
name: Docker Build and Push
on:
push:
branches: [ "main" ]
paths:
- 'src/main/cpp/**'
- 'src/main/go/**'
- 'src/main/scala/**'
- 'src/main/protobuf/**'
- 'src/main/resources/**'
- 'ci/BUILD.bazel'
- 'MODULE.bazel'
- '.github/workflows/docker_build.yml'
workflow_dispatch:
inputs:
push_images:
description: 'Push images to container registry'
required: true
default: 'false'
type: boolean
permissions:
contents: read
jobs:
build-eagle:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-eagle.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Eagle Docker image
id: build-eagle
run: |
set -ex
bazel build --platforms=//:linux_x86_64 //ci:eagle_server_image
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
- 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
# Also set for current directory in case Bazel uses different home
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push Eagle image to DO registry
id: push-eagle
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
# Use cross-compiled image path from build step
EAGLE_IMAGE="${{ steps.build-eagle.outputs.image_path }}"
echo "Using Eagle image: $EAGLE_IMAGE"
if [ -z "$EAGLE_IMAGE" ] || [ ! -d "$EAGLE_IMAGE" ]; then
echo "ERROR: Eagle image not found at: $EAGLE_IMAGE"
exit 1
fi
# Debug: show OCI layout contents
echo "=== OCI Layout Contents ==="
cat "$EAGLE_IMAGE/index.json"
echo ""
echo "=== Blobs ==="
ls -la "$EAGLE_IMAGE/blobs/sha256/" | head -20
# Verify OCI layout consistency before pushing
echo "=== Verifying OCI layout consistency ==="
for digest in $(cat "$EAGLE_IMAGE/index.json" | grep -o '"sha256:[^"]*"' | tr -d '"'); do
blob_path="$EAGLE_IMAGE/blobs/${digest/://}"
if [ ! -f "$blob_path" ]; then
echo "ERROR: Blob not found: $blob_path"
exit 1
fi
actual_digest="sha256:$(shasum -a 256 "$blob_path" | cut -d' ' -f1)"
if [ "$digest" != "$actual_digest" ]; then
echo "ERROR: Digest mismatch for $blob_path"
echo " Index says: $digest"
echo " Actual: $actual_digest"
exit 1
fi
echo "✓ Verified: $digest"
done
# Build the push target to get crane in runfiles
bazel build //ci:eagle_server_push
# Use crane directly for push (avoids OCI->Docker digest mismatch)
CRANE="bazel-bin/ci/push_eagle_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
echo "Using crane: $CRANE"
# Push with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/eagle-server:${GIT_SHA}"
echo "Pushing eagle image: $IMAGE_TAG"
$CRANE push "$EAGLE_IMAGE" "$IMAGE_TAG"
# Verify push by checking what's in the registry
echo "=== Verifying push ==="
$CRANE manifest "$IMAGE_TAG" | head -50
PUSHED_DIGEST=$($CRANE digest "$IMAGE_TAG")
echo "Registry reports digest: $PUSHED_DIGEST"
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience (but deploy won't use it)
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest"
build-shardok:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Shardok binary (cross-compile for Linux)
run: |
set -ex
# Step 1: Build JUST the binary with cross-compilation
# We need --extra_toolchains to force the Linux toolchain to be used
# because toolchains_llvm registers with dev_dependency=True
echo "=== Building shardok-server binary for linux-x86_64 ==="
bazel build \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:all \
//src/main/cpp/net/eagle0/shardok:shardok-server
# Step 2: Check the binary directly from bazel-bin
# bazel-bin is a symlink that points to the correct output directory
LINUX_BIN="bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server"
echo "=== Checking binary at: $LINUX_BIN ==="
if [ ! -f "$LINUX_BIN" ]; then
echo "ERROR: Binary not found at $LINUX_BIN"
exit 1
fi
# Debug: show what bazel-bin points to
echo "bazel-bin symlink target: $(readlink bazel-bin || echo 'not a symlink')"
# Step 3: Verify it's ELF (Linux) not Mach-O (macOS)
echo "=== Verifying binary format ==="
MAGIC=$(head -c 4 "$LINUX_BIN" | xxd -p)
echo "Binary magic bytes: $MAGIC"
if [ "$MAGIC" = "7f454c46" ]; then
echo "SUCCESS: Binary is ELF format (Linux)"
elif [ "$MAGIC" = "cfaeedfe" ] || [ "$MAGIC" = "cffaedfe" ]; then
echo "ERROR: Binary is Mach-O format (macOS) - cross-compilation failed!"
echo ""
echo "Debug info:"
echo "- bazel-bin points to: $(readlink bazel-bin)"
file "$LINUX_BIN" || true
exit 1
else
echo "WARNING: Unknown binary format: $MAGIC"
file "$LINUX_BIN" || true
fi
- name: Build Shardok Docker image
id: build-shardok
run: |
set -ex
# Build the OCI image with cross-compilation flags
bazel build \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:all \
//ci:shardok_server_image
# The image is output to bazel-bin which is a symlink.
# Resolve it now before any other bazel commands change where it points.
IMAGE_PATH=$(readlink -f bazel-bin/ci/shardok_server_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
# Verify the binary inside the tar layer is ELF
echo "=== Verifying binary in image tar ==="
BINARY_TAR="bazel-bin/ci/shardok_binary_layer.tar"
if [ -f "$BINARY_TAR" ]; then
echo "Checking binary in $BINARY_TAR"
# Extract just the first 4 bytes of the binary from the tar
MAGIC=$(tar -xOf "$BINARY_TAR" app/shardok-server 2>/dev/null | head -c 4 | xxd -p)
echo "Binary magic in tar: $MAGIC"
if [ "$MAGIC" = "7f454c46" ]; then
echo "SUCCESS: Binary in tar is ELF format (Linux)"
else
echo "ERROR: Binary in tar is NOT ELF format!"
echo "This means pkg_tar is packaging the wrong binary."
exit 1
fi
else
echo "WARNING: Could not find $BINARY_TAR"
fi
- name: 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
# Also set for current directory in case Bazel uses different home
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push Shardok image to DO registry
id: push-shardok
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
# Use cross-compiled image path from build step
CROSS_IMAGE="${{ steps.build-shardok.outputs.image_path }}"
echo "Using cross-compiled image: $CROSS_IMAGE"
if [ -z "$CROSS_IMAGE" ] || [ ! -d "$CROSS_IMAGE" ]; then
echo "ERROR: Cross-compiled image not found at: $CROSS_IMAGE"
exit 1
fi
# Get crane from Eagle push target (which doesn't need cross-compilation)
# This gives us a macOS crane binary we can actually run.
# We can't build shardok_server_push with platform flags because it would
# download a Linux crane that can't run on macOS.
bazel build //ci:eagle_server_push
# Find the Darwin crane binary (may be a symlink)
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
if [ -z "$CRANE" ]; then
# Fallback to any crane
CRANE=$(find "$RUNFILES" -name crane 2>/dev/null | head -1)
fi
if [ -z "$CRANE" ] || [ ! -e "$CRANE" ]; then
echo "ERROR: crane not found. Listing runfiles:"
find "$RUNFILES" -name crane 2>/dev/null || true
exit 1
fi
echo "Using crane: $CRANE"
# Push the cross-compiled image with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/shardok-server:${GIT_SHA}"
echo "Pushing shardok image: $IMAGE_TAG"
$CRANE push "$CROSS_IMAGE" "$IMAGE_TAG"
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience (but deploy won't use it)
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/shardok-server:latest"
build-admin:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-admin.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Admin Server Docker image
id: build-admin
run: |
set -ex
# Build admin server image (Go binary has explicit goos/goarch in BUILD.bazel)
bazel build //ci:admin_server_image
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/admin_server_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push Admin image to DO registry
id: push-admin
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
ADMIN_IMAGE="${{ steps.build-admin.outputs.image_path }}"
echo "Using Admin image: $ADMIN_IMAGE"
if [ -z "$ADMIN_IMAGE" ] || [ ! -d "$ADMIN_IMAGE" ]; then
echo "ERROR: Admin image not found at: $ADMIN_IMAGE"
exit 1
fi
# Build the push target to get crane in runfiles
bazel build //ci:admin_server_push
# Use crane directly for push
CRANE="bazel-bin/ci/push_admin_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
echo "Using crane: $CRANE"
# Push with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/admin-server:${GIT_SHA}"
echo "Pushing admin image: $IMAGE_TAG"
$CRANE push "$ADMIN_IMAGE" "$IMAGE_TAG"
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/admin-server:latest"
deploy:
runs-on: ubuntu-latest
needs: [build-eagle, build-shardok, build-admin]
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
environment: production
env:
EAGLE_IMAGE: ${{ needs.build-eagle.outputs.image_tag }}
SHARDOK_IMAGE: ${{ needs.build-shardok.outputs.image_tag }}
ADMIN_IMAGE: ${{ needs.build-admin.outputs.image_tag }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GPT_MODEL_NAME: ${{ secrets.GPT_MODEL_NAME }}
EAGLE_ENABLE_S3: ${{ secrets.EAGLE_ENABLE_S3 }}
DO_SPACES_ACCESS_KEY: ${{ secrets.DO_SPACES_ACCESS_KEY }}
DO_SPACES_SECRET_KEY: ${{ secrets.DO_SPACES_SECRET_KEY }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Copy config files to droplet
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
source: "docker-compose.prod.yml,nginx/nginx.conf"
target: "/opt/eagle0"
- name: Deploy to production droplet
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
script_stop: true
envs: EAGLE_IMAGE,SHARDOK_IMAGE,ADMIN_IMAGE,OPENAI_API_KEY,GPT_MODEL_NAME,EAGLE_ENABLE_S3,DO_SPACES_ACCESS_KEY,DO_SPACES_SECRET_KEY
script: |
set -x
cd /opt/eagle0
# Write env vars to .env file for docker-compose
rm -f .env 2>/dev/null || true
cat > .env << EOF
OPENAI_API_KEY=${OPENAI_API_KEY:-}
GPT_MODEL_NAME=${GPT_MODEL_NAME:-gpt-4o}
EAGLE_ENABLE_S3=${EAGLE_ENABLE_S3:-false}
DO_SPACES_ACCESS_KEY=${DO_SPACES_ACCESS_KEY:-}
DO_SPACES_SECRET_KEY=${DO_SPACES_SECRET_KEY:-}
EOF
chmod 600 .env
# Login to registry
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
# Use exact image tags passed from build jobs (no :latest fallback)
echo "Using images: $EAGLE_IMAGE, $SHARDOK_IMAGE, $ADMIN_IMAGE"
# Use crane to pull images (handles OCI format correctly) then load into Docker
# This avoids digest mismatch from DO registry's OCI->Docker format conversion
echo "Installing crane..."
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
chmod +x crane
# crane uses Docker config for auth
echo "Pulling Eagle image with crane..."
./crane pull "${EAGLE_IMAGE}" eagle.tar || { echo "ERROR: Failed to pull eagle image"; exit 1; }
echo "Loading Eagle image into Docker..."
docker load -i eagle.tar
rm eagle.tar
echo "Pulling Shardok image with crane..."
./crane pull "${SHARDOK_IMAGE}" shardok.tar || { echo "ERROR: Failed to pull shardok image"; exit 1; }
echo "Loading Shardok image into Docker..."
docker load -i shardok.tar
rm shardok.tar
echo "Pulling Admin image with crane..."
./crane pull "${ADMIN_IMAGE}" admin.tar || { echo "ERROR: Failed to pull admin image"; exit 1; }
echo "Loading Admin image into Docker..."
docker load -i admin.tar
rm admin.tar
rm ./crane
# Also pull other compose images
docker pull nginx:alpine || true
docker pull certbot/certbot || true
echo "All images pulled successfully"
# Force recreate containers to ensure new image is used
docker compose -f docker-compose.prod.yml up -d --force-recreate --remove-orphans
# Restart nginx to pick up new container IPs
# (nginx caches DNS at startup, so it needs restart after eagle/shardok)
docker compose -f docker-compose.prod.yml restart nginx
# Wait for health checks
sleep 10
docker compose -f docker-compose.prod.yml ps
# Verify containers are using correct images
echo "=== Verifying container image tags ==="
docker compose -f docker-compose.prod.yml images
# Cleanup old images
docker image prune -f
-9
View File
@@ -3,15 +3,6 @@ load("@io_bazel_rules_go//go:def.bzl", "nogo")
package(default_visibility = ["//visibility:public"])
# Platform for cross-compiling to Linux x86_64
platform(
name = "linux_x86_64",
constraint_values = [
"@platforms//os:linux",
"@platforms//cpu:x86_64",
],
)
gazelle(name = "gazelle")
# gazelle:proto file
-90
View File
@@ -1,90 +0,0 @@
# Deproto Migration Plan
This document tracks the migration from protobuf types to native Scala models inside the Eagle game engine.
## Architectural Decisions
1. **Keep proto for persistence**: Yes - protobuf is used for persisting game state
2. **Keep proto for Shardok communication**: Yes - protobuf is used for Eagle-Shardok gRPC communication
3. **Use Scala views inside the library**: Yes - use native Scala types like `ProvinceView`, `FactionView`, `HeroView`, etc. within the library code
## Migration Pattern
The codebase follows a **Legacy* pattern** for separating proto-dependent and protoless code:
- **Protoless utilities**: `FactionUtils`, `HeroUtils`, `ProvinceUtils`, `ProvinceDistances`, etc.
- **Proto-dependent utilities**: `LegacyFactionUtils`, `LegacyHeroUtils`, `LegacyProvinceUtils`, `LegacyProvinceDistances`, etc.
When migrating a file:
1. Create a `Legacy*` version containing the proto-dependent methods
2. Keep the original file name for protoless methods
3. Update callers to use the appropriate version based on their context
## Migration Status
### Fully Protoless (no proto imports)
**Utilities:**
- [x] `FactionUtils` - has protoless `ownedNeighbors` method
- [x] `ProvinceDistances` - split into protoless + `LegacyProvinceDistances`
- [x] `SwornBrotherChooser` - fully protoless (removed `bestChoiceProto`)
**Command Selectors (all use native GameState):**
- [x] `AllianceOfferCommandSelector`
- [x] `AlmsCommandSelector`
- [x] `AttackCommandChooser`
- [x] `ExpandCommandSelector`
- [x] `HeroGiftCommandSelector`
- [x] `ImproveCommandSelector`
- [x] `OrganizeCommandSelector`
- [x] `RansomOfferHelpers`
- [x] `SeekMoreLeadersCommandChooser` - in AI folder, uses native GameState
- [x] `TruceOfferCommandSelector`
- [x] `TrustForDiplomacy`
**Quest Command Selectors (all protoless):**
- [x] `AllianceQuestCommandChooser`
- [x] `AlmsAcrossRealmQuestCommandChooser`
- [x] `AlmsToProvinceQuestCommandChooser`
- [x] `DismissSpecificVassalCommandChooser`
- [x] `GiveToHeroesAcrossRealmQuestCommandChooser`
- [x] `GiveToHeroesInProvinceQuestCommandChooser`
- [x] `ImproveQuestCommandChooser`
- [x] `QuestCommandChooser`
- [x] `TruceCountQuestCommandChooser`
- [x] `TruceWithFactionQuestCommandChooser`
### Dual (both proto and protoless versions)
- [~] `ProvinceGoldSurplusCalculator` - protoless `provinceGoldSurplus(province: ProvinceT)` + legacy `provinceGoldSurplus(provinceId, gameState)`
- [~] `HeroSelector` - protoless `minimallyFatiguedHeroes` + legacy `minimallyFatiguedHeroesProto`
### Blocked (still uses proto GameState)
- [ ] `CommandChoiceHelpers` - main target, uses proto GameState extensively
- Only 1 call to `minimallyFatiguedHeroesProto` (HeroSelector)
- Many calls to proto `provinceGoldSurplus`
- Depends on many Legacy* utils
- [ ] `AttackDecisionCommandChooser` - uses proto types
- [ ] `CommandChooser` - uses proto GameState
- [ ] `FulfillQuestsCommandSelector` - takes proto, converts to native immediately
- Called by `MidGameAIClient` which uses proto GameState
## Key Files
### Protoless Model Types
- `src/main/scala/net/eagle0/eagle/model/state/game_state/GameState.scala` - native Scala GameState
- `src/main/scala/net/eagle0/eagle/model/state/province/ProvinceView.scala` - province view type
- `src/main/scala/net/eagle0/eagle/model/state/faction/FactionView.scala` - faction view type
- `src/main/scala/net/eagle0/eagle/model/state/hero/HeroView.scala` - hero view type
### Proto Converters
- `src/main/scala/net/eagle0/eagle/model/proto_converters/game_state/` - converts between proto and Scala types
## Notes
- The AI client code (`src/main/scala/net/eagle0/eagle/ai/`) currently uses proto types extensively
- `PerformUnaffiliatedHeroesAction` already uses protoless `GameState`
- Migration should proceed incrementally: utilities first, then higher-level selectors/choosers
+18 -83
View File
@@ -26,68 +26,50 @@ scala_config = use_extension(
"@rules_scala//scala/extensions:config.bzl",
"scala_config",
)
scala_config.settings(scala_version = SCALA_VERSION)
scala_deps = use_extension(
"@rules_scala//scala/extensions:deps.bzl",
"scala_deps",
)
scala_deps.scala()
scala_deps.scalatest()
scala_deps.scala_proto()
#
# Language Support - C++
#
bazel_dep(name = "toolchains_llvm", version = "1.6.0")
bazel_dep(name = "toolchains_llvm", version = "1.4.0")
llvm = use_extension("@toolchains_llvm//toolchain/extensions:llvm.bzl", "llvm")
# Native toolchain (macOS -> macOS, Linux -> Linux)
llvm.toolchain(
name = "llvm_toolchain",
llvm_version = "20.1.2",
)
# Cross-compilation toolchain (macOS -> Linux x86_64)
# Uses the same LLVM distribution but with a Linux sysroot
llvm.toolchain(
name = "llvm_toolchain_linux",
llvm_version = "20.1.2",
)
# Linux sysroot for cross-compilation (Chromium's Debian sysroot)
llvm.sysroot(
name = "llvm_toolchain_linux",
label = "@linux_sysroot//sysroot",
targets = ["linux-x86_64"],
)
use_repo(llvm, "llvm_toolchain", "llvm_toolchain_linux")
# Download the Linux sysroot (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")
sysroot(
name = "linux_sysroot",
sha256 = "a06475004fe8003ae7ccb4fe1d5511feb9b27cce4a8826eb1dfd686ed83f3dba",
urls = ["https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/v3/ubuntu_noble_amd64_sysroot.tar.xz"],
)
use_repo(llvm, "llvm_toolchain")
#
# Language Support - Go
#
bazel_dep(name = "rules_go", version = "0.56.1", repo_name = "io_bazel_rules_go")
bazel_dep(name = "gazelle", version = "0.45.0", repo_name = "bazel_gazelle")
bazel_dep(name = "rules_go", repo_name = "io_bazel_rules_go", version = "0.56.1")
bazel_dep(name = "gazelle", repo_name = "bazel_gazelle", version = "0.45.0")
go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk")
go_sdk.download(version = "1.23.3")
go_deps = use_extension("@bazel_gazelle//:extensions.bzl", "go_deps")
go_deps.from_file(go_mod = "//:go.mod")
use_repo(
go_deps,
"com_github_aws_aws_sdk_go_v2",
@@ -102,15 +84,15 @@ use_repo(
# Platform Support - Apple/iOS
#
bazel_dep(name = "apple_support", version = "1.21.1", repo_name = "build_bazel_apple_support")
bazel_dep(name = "rules_apple", version = "3.16.1", repo_name = "build_bazel_rules_apple")
bazel_dep(name = "rules_swift", version = "2.3.1", repo_name = "build_bazel_rules_swift")
bazel_dep(name = "apple_support", repo_name = "build_bazel_apple_support", version = "1.21.1")
bazel_dep(name = "rules_apple", repo_name = "build_bazel_rules_apple", version = "3.16.1")
bazel_dep(name = "rules_swift", repo_name = "build_bazel_rules_swift", version = "2.3.1")
#
# Protocol Buffers & RPC
#
bazel_dep(name = "protobuf", version = "29.2", repo_name = "com_google_protobuf")
bazel_dep(name = "protobuf", repo_name = "com_google_protobuf", version = "29.2")
bazel_dep(name = "grpc", version = "1.71.0")
bazel_dep(name = "grpc-java", version = "1.71.0")
bazel_dep(name = "flatbuffers", version = "25.2.10")
@@ -121,40 +103,6 @@ bazel_dep(name = "flatbuffers", version = "25.2.10")
bazel_dep(name = "googletest", version = "1.17.0")
#
# Container Images (OCI)
#
bazel_dep(name = "rules_oci", version = "2.2.6")
bazel_dep(name = "aspect_bazel_lib", version = "2.16.0")
oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
# Base image for Eagle (Java 17)
oci.pull(
name = "eclipse_temurin_17",
digest = "sha256:d286b5352d98777bbf727f54038b04f0145cd9b76ca83f38a67aa111d4303748",
image = "docker.io/library/eclipse-temurin",
platforms = ["linux/amd64"],
)
# Base image for Shardok (Ubuntu 24.04 for C++ runtime)
oci.pull(
name = "ubuntu_24_04",
image = "docker.io/library/ubuntu",
platforms = ["linux/amd64"],
tag = "24.04",
)
# Base image for Admin Server (Alpine for lightweight Go binary)
oci.pull(
name = "alpine_linux",
image = "docker.io/library/alpine",
platforms = ["linux/amd64"],
tag = "3.21",
)
use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_17", "eclipse_temurin_17_linux_amd64", "ubuntu_24_04", "ubuntu_24_04_linux_amd64")
#
# Java/Scala Dependencies
#
@@ -162,6 +110,7 @@ use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_17",
bazel_dep(name = "rules_jvm_external", version = "6.3")
maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")
maven.install(
artifacts = [
# Netty
@@ -213,12 +162,9 @@ maven.install(
"org.reactivestreams:reactive-streams:1.0.4",
"javax.xml.bind:jaxb-api:2.3.1",
# OkHttp (for SSE with read timeout support, OAuth HTTP calls)
# OkHttp (for SSE with read timeout support)
"com.squareup.okhttp3:okhttp:4.12.0",
"com.squareup.okhttp3:okhttp-sse:4.12.0",
# JWT (for OAuth token handling)
"com.nimbusds:nimbus-jose-jwt:9.37.3",
],
duplicate_version_warning = "error",
fail_if_repin_required = True,
@@ -227,6 +173,7 @@ maven.install(
"https://repo1.maven.org/maven2",
],
)
use_repo(maven, "maven", "unpinned_maven")
#
@@ -234,7 +181,6 @@ use_repo(maven, "maven", "unpinned_maven")
#
http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file")
# GTL (for parallel_hashmap)
GTL_VERSION = "1.2.0"
@@ -263,16 +209,6 @@ http_archive(
],
)
# Busybox static binary for Docker health checks (provides nc, wget, etc.)
# https://busybox.net/downloads/binaries/
http_file(
name = "busybox_x86_64",
sha256 = "6e123e7f3202a8c1e9b1f94d8941580a25135382b99e8d3e34fb858bba311348",
urls = ["https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox"],
downloaded_file_path = "busybox",
executable = True,
)
#
# Toolchain Registration
#
@@ -285,6 +221,5 @@ register_toolchains(
# Set dev_dependency so we can turn this off for swift MacOS builds
register_toolchains(
"@llvm_toolchain//:all",
"@llvm_toolchain_linux//:all",
dev_dependency = True,
)
+88 -308
View File
@@ -26,11 +26,7 @@
"https://bcr.bazel.build/modules/aspect_bazel_lib/1.38.0/MODULE.bazel": "6307fec451ba9962c1c969eb516ebfe1e46528f7fa92e1c9ac8646bef4cdaa3f",
"https://bcr.bazel.build/modules/aspect_bazel_lib/1.40.3/MODULE.bazel": "668e6bcb4d957fc0e284316dba546b705c8d43c857f87119619ee83c4555b859",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.11.0/MODULE.bazel": "cb1ba9f9999ed0bc08600c221f532c1ddd8d217686b32ba7d45b0713b5131452",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.14.0/MODULE.bazel": "2b31ffcc9bdc8295b2167e07a757dbbc9ac8906e7028e5170a3708cecaac119f",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.16.0/MODULE.bazel": "852f9ebbda017572a7c113a2434592dd3b2f55cd9a0faea3d4be5a09a59e4900",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/MODULE.bazel": "253d739ba126f62a5767d832765b12b59e9f8d2bc88cc1572f4a73e46eb298ca",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/source.json": "ffab9254c65ba945f8369297ad97ca0dec213d3adc6e07877e23a48624a8b456",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.2/MODULE.bazel": "780d1a6522b28f5edb7ea09630748720721dfe27690d65a2d33aa7509de77e07",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.11.0/source.json": "92494d5aa43b96665397dd13ee16023097470fa85e276b93674d62a244de47ee",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.7/MODULE.bazel": "491f8681205e31bb57892d67442ce448cda4f472a8e6b3dc062865e29a64f89c",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.8.1/MODULE.bazel": "812d2dd42f65dca362152101fbec418029cc8fd34cbad1a2fde905383d705838",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.9.3/MODULE.bazel": "66baf724dbae7aff4787bf2245cc188d50cb08e07789769730151c0943587c14",
@@ -55,11 +51,8 @@
"https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58",
"https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b",
"https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65",
"https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d",
"https://bcr.bazel.build/modules/bazel_features/1.27.0/source.json": "ed8cf0ef05c858dce3661689d0a2b110ff398e63994e178e4f1f7555a8067fed",
"https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9",
"https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e",
"https://bcr.bazel.build/modules/bazel_features/1.38.0/MODULE.bazel": "f9b8a9c890ebd216b4049fd12a31d3c2602e3403c7af636b04fbbd7453edc9c9",
"https://bcr.bazel.build/modules/bazel_features/1.38.0/source.json": "31ba776c122b54a2885e23651642e32f087a87bf025465f8040751894b571277",
"https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7",
"https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b",
"https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a",
@@ -76,8 +69,7 @@
"https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d",
"https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.2/source.json": "34a3c8bcf233b835eb74be9d628899bb32999d3e0eadef1947a0a562a2b16ffb",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.1/source.json": "7ebaefba0b03efe59cac88ed5bbc67bcf59a3eff33af937345ede2a38b2d368a",
"https://bcr.bazel.build/modules/bazel_worker_api/0.0.6/MODULE.bazel": "fd1f9432ca04c947e91b500df69ce7c5b6dbfe1bc45ab1820338205dae3383a6",
"https://bcr.bazel.build/modules/bazel_worker_api/0.0.6/source.json": "5d68545f224904745a3cabd35aea6bc2b6cc5a78b7f49f3f69660eab2eeeb273",
"https://bcr.bazel.build/modules/boringssl/0.0.0-20211025-d4f1ab9/MODULE.bazel": "6ee6353f8b1a701fe2178e1d925034294971350b6d3ac37e67e5a7d463267834",
@@ -106,8 +98,6 @@
"https://bcr.bazel.build/modules/envoy_api/0.0.0-20250128-4de3c74/source.json": "028519164a2e24563f4b43d810fdedc702daed90e71e7042d45ba82ad807b46f",
"https://bcr.bazel.build/modules/flatbuffers/25.2.10/MODULE.bazel": "dab15cafe8512d2c4a8daa44c2d7968c5c79f01e220d40076cdc260bf58605e2",
"https://bcr.bazel.build/modules/flatbuffers/25.2.10/source.json": "7eae7ea3eb913b9802426e4d5df11d6c6072a3573a548f8cabf1e965f5cca4d0",
"https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/MODULE.bazel": "cdf8cbe5ee750db04b78878c9633cc76e80dcf4416cbe982ac3a9222f80713c8",
"https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/source.json": "fa7b512dfcb5eafd90ce3959cf42a2a6fe96144ebbb4b3b3928054895f2afac2",
"https://bcr.bazel.build/modules/gazelle/0.27.0/MODULE.bazel": "3446abd608295de6d90b4a8a118ed64a9ce11dcb3dda2dc3290a22056bd20996",
"https://bcr.bazel.build/modules/gazelle/0.30.0/MODULE.bazel": "f888a1effe338491f35f0e0e85003b47bb9d8295ccba73c37e07702d8d31c65b",
"https://bcr.bazel.build/modules/gazelle/0.32.0/MODULE.bazel": "b499f58a5d0d3537f3cf5b76d8ada18242f64ec474d8391247438bf04f58c7b8",
@@ -147,10 +137,6 @@
"https://bcr.bazel.build/modules/grpc/1.70.1/MODULE.bazel": "b800cd8e3e7555c1e61cba2e02d3a2fcf0e91f66e800db286d965d3b7a6a721a",
"https://bcr.bazel.build/modules/grpc/1.71.0/MODULE.bazel": "7fcab2c05530373f1a442c362b17740dd0c75b6a2a975eec8f5bf4c70a37928a",
"https://bcr.bazel.build/modules/grpc/1.71.0/source.json": "60ef8c4c72c8280ae94c05b4f38bf67785acb25477ab8dbac096a9604449ff90",
"https://bcr.bazel.build/modules/helly25_bzl/0.3.1/MODULE.bazel": "3a4be20f6fc13be32ad44643b8252ef5af09eee936f1d943cd4fd7867fa92826",
"https://bcr.bazel.build/modules/helly25_bzl/0.3.1/source.json": "b129ab1828492de2c163785bbeb4065c166de52d932524b4317beb5b7f917994",
"https://bcr.bazel.build/modules/jq.bzl/0.1.0/MODULE.bazel": "2ce69b1af49952cd4121a9c3055faa679e748ce774c7f1fda9657f936cae902f",
"https://bcr.bazel.build/modules/jq.bzl/0.1.0/source.json": "746bf13cac0860f091df5e4911d0c593971cd8796b5ad4e809b2f8e133eee3d5",
"https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075",
"https://bcr.bazel.build/modules/jsoncpp/1.9.5/source.json": "4108ee5085dd2885a341c7fab149429db457b3169b86eb081fa245eadf69169d",
"https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902",
@@ -175,7 +161,6 @@
"https://bcr.bazel.build/modules/opentelemetry-proto/1.5.0/source.json": "046b721ce203e88cdaad44d7dd17a86b7200eab9388b663b234e72e13ff7b143",
"https://bcr.bazel.build/modules/opentracing-cpp/1.6.0/MODULE.bazel": "b3925269f63561b8b880ae7cf62ccf81f6ece55b62cd791eda9925147ae116ec",
"https://bcr.bazel.build/modules/opentracing-cpp/1.6.0/source.json": "da1cb1add160f5e5074b7272e9db6fd8f1b3336c15032cd0a653af9d2f484aed",
"https://bcr.bazel.build/modules/package_metadata/0.0.2/MODULE.bazel": "fb8d25550742674d63d7b250063d4580ca530499f045d70748b1b142081ebb92",
"https://bcr.bazel.build/modules/package_metadata/0.0.5/MODULE.bazel": "ef4f9439e3270fdd6b9fd4dbc3d2f29d13888e44c529a1b243f7a31dfbc2e8e4",
"https://bcr.bazel.build/modules/package_metadata/0.0.5/source.json": "2326db2f6592578177751c3e1f74786b79382cd6008834c9d01ec865b9126a85",
"https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5",
@@ -240,14 +225,12 @@
"https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc",
"https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87",
"https://bcr.bazel.build/modules/rules_cc/0.0.17/MODULE.bazel": "2ae1d8f4238ec67d7185d8861cb0a2cdf4bc608697c331b95bf990e69b62e64a",
"https://bcr.bazel.build/modules/rules_cc/0.0.17/source.json": "4db99b3f55c90ab28d14552aa0632533e3e8e5e9aea0f5c24ac0014282c2a7c5",
"https://bcr.bazel.build/modules/rules_cc/0.0.2/MODULE.bazel": "6915987c90970493ab97393024c156ea8fb9f3bea953b2f3ec05c34f19b5695c",
"https://bcr.bazel.build/modules/rules_cc/0.0.5/MODULE.bazel": "be41f87587998fe8890cd82ea4e848ed8eb799e053c224f78f3ff7fe1a1d9b74",
"https://bcr.bazel.build/modules/rules_cc/0.0.6/MODULE.bazel": "abf360251023dfe3efcef65ab9d56beefa8394d4176dd29529750e1c57eaa33f",
"https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e",
"https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5",
"https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513",
"https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8",
"https://bcr.bazel.build/modules/rules_cc/0.2.14/source.json": "55d0a4587c5592fad350f6e698530f4faf0e7dd15e69d43f8d87e220c78bea54",
"https://bcr.bazel.build/modules/rules_foreign_cc/0.10.1/MODULE.bazel": "b9527010e5fef060af92b6724edb3691970a5b1f76f74b21d39f7d433641be60",
"https://bcr.bazel.build/modules/rules_foreign_cc/0.10.1/source.json": "9300e71df0cdde0952f10afff1401fa664e9fc5d9ae6204660ba1b158d90d6a6",
"https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6",
@@ -306,8 +289,6 @@
"https://bcr.bazel.build/modules/rules_nodejs/6.3.0/MODULE.bazel": "45345e4aba35dd6e4701c1eebf5a4e67af4ed708def9ebcdc6027585b34ee52d",
"https://bcr.bazel.build/modules/rules_nodejs/6.3.3/MODULE.bazel": "b66eadebd10f1f1b25f52f95ab5213a57e82c37c3f656fcd9a57ad04d2264ce7",
"https://bcr.bazel.build/modules/rules_nodejs/6.3.3/source.json": "45bd343155bdfed2543f0e39b80ff3f6840efc31975da4b5795797f4c94147ad",
"https://bcr.bazel.build/modules/rules_oci/2.2.6/MODULE.bazel": "2ba6ddd679269e00aeffe9ca04faa2d0ca4129650982c9246d0d459fe2da47d9",
"https://bcr.bazel.build/modules/rules_oci/2.2.6/source.json": "94e7decb8f95d9465b0bbea71c65064cd16083be1350c7468f131818641dc4a5",
"https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc",
"https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff",
"https://bcr.bazel.build/modules/rules_pkg/1.1.0/MODULE.bazel": "9db8031e71b6ef32d1846106e10dd0ee2deac042bd9a2de22b4761b0c3036453",
@@ -340,8 +321,7 @@
"https://bcr.bazel.build/modules/rules_scala/7.1.1/source.json": "5038cb231d4020c5965c920681cf961a7bf137b40315025e40f3a7b6a0ac1f0f",
"https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c",
"https://bcr.bazel.build/modules/rules_shell/0.3.0/MODULE.bazel": "de4402cd12f4cc8fda2354fce179fdb068c0b9ca1ec2d2b17b3e21b24c1a937b",
"https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592",
"https://bcr.bazel.build/modules/rules_shell/0.4.1/source.json": "4757bd277fe1567763991c4425b483477bb82e35e777a56fd846eb5cceda324a",
"https://bcr.bazel.build/modules/rules_shell/0.3.0/source.json": "c55ed591aa5009401ddf80ded9762ac32c358d2517ee7820be981e2de9756cf3",
"https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca",
"https://bcr.bazel.build/modules/rules_swift/1.18.0/MODULE.bazel": "a6aba73625d0dc64c7b4a1e831549b6e375fbddb9d2dde9d80c9de6ec45b24c9",
"https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046",
@@ -359,19 +339,14 @@
"https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216",
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91",
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/source.json": "32bd87e5f4d7acc57c5b2ff7c325ae3061d5e242c0c4c214ae87e0f1c13e54cb",
"https://bcr.bazel.build/modules/tar.bzl/0.2.1/MODULE.bazel": "52d1c00a80a8cc67acbd01649e83d8dd6a9dc426a6c0b754a04fe8c219c76468",
"https://bcr.bazel.build/modules/tar.bzl/0.6.0/MODULE.bazel": "a3584b4edcfafcabd9b0ef9819808f05b372957bbdff41601429d5fd0aac2e7c",
"https://bcr.bazel.build/modules/tar.bzl/0.6.0/source.json": "4a620381df075a16cb3a7ed57bd1d05f7480222394c64a20fa51bdb636fda658",
"https://bcr.bazel.build/modules/toolchains_llvm/1.6.0/MODULE.bazel": "39603859cafb1c6830160fcd6370552e836790e6abb2bfb8d13bff53c0c10a64",
"https://bcr.bazel.build/modules/toolchains_llvm/1.6.0/source.json": "6bd3ef95a288dd2bb1582eca332af850c9a5428a23bb92cb1c57c2dfe6cb7369",
"https://bcr.bazel.build/modules/toolchains_llvm/1.4.0/MODULE.bazel": "05239402b7374293359c2f22806f420b75aa5d6f4b15a2eaa809a2c214d58b31",
"https://bcr.bazel.build/modules/toolchains_llvm/1.4.0/source.json": "229a516d282b17a82be54c6e3ae220a1b750fb55a8495567e5c7a9d09423f3e2",
"https://bcr.bazel.build/modules/upb/0.0.0-20211020-160625a/MODULE.bazel": "6cced416be2dc5b9c05efd5b997049ba795e5e4e6fafbe1624f4587767638928",
"https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43",
"https://bcr.bazel.build/modules/upb/0.0.0-20230516-61a97ef/MODULE.bazel": "c0df5e35ad55e264160417fd0875932ee3c9dda63d9fccace35ac62f45e1b6f9",
"https://bcr.bazel.build/modules/upb/0.0.0-20230907-e7430e6/MODULE.bazel": "3a7dedadf70346e678dc059dbe44d05cbf3ab17f1ce43a1c7a42edc7cbf93fd9",
"https://bcr.bazel.build/modules/xds/0.0.0-20240423-555b57e/MODULE.bazel": "cea509976a77e34131411684ef05a1d6ad194dd71a8d5816643bc5b0af16dc0f",
"https://bcr.bazel.build/modules/xds/0.0.0-20240423-555b57e/source.json": "7227e1fcad55f3f3cab1a08691ecd753cb29cc6380a47bc650851be9f9ad6d20",
"https://bcr.bazel.build/modules/yq.bzl/0.1.1/MODULE.bazel": "9039681f9bcb8958ee2c87ffc74bdafba9f4369096a2b5634b88abc0eaefa072",
"https://bcr.bazel.build/modules/yq.bzl/0.1.1/source.json": "2d2bad780a9f2b9195a4a370314d2c17ae95eaa745cefc2e12fbc49759b15aa3",
"https://bcr.bazel.build/modules/zlib/1.2.11/MODULE.bazel": "07b389abc85fdbca459b69e2ec656ae5622873af3f845e1c9d80fe179f3effa0",
"https://bcr.bazel.build/modules/zlib/1.2.12/MODULE.bazel": "3b1a8834ada2a883674be8cbd36ede1b6ec481477ada359cd2d3ddc562340b27",
"https://bcr.bazel.build/modules/zlib/1.2.13/MODULE.bazel": "aa6deb1b83c18ffecd940c4119aff9567cd0a671d7bba756741cb2ef043a29d5",
@@ -413,7 +388,7 @@
},
"@@aspect_rules_esbuild~//esbuild:extensions.bzl%esbuild": {
"general": {
"bzlTransitiveDigest": "8jv3p0xDR/oitFeH8y0+Y5xlyrUbfsTRlc9TSwYkwl8=",
"bzlTransitiveDigest": "8iOqbPY5ve3DvjzaI1mJZ8XTiJypN2PeWvcKOvmZLy8=",
"usagesDigest": "iDVoyPxUeADmfK8ssoyG3Ehq1bj6p7A43LpEiE266os=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
@@ -1290,280 +1265,6 @@
"recordedRepoMappingEntries": []
}
},
"@@rules_oci~//oci:extensions.bzl%oci": {
"general": {
"bzlTransitiveDigest": "FaY+7xb13bB3hmxqwAWaGp3Tf3Q4Nfdlr+F38CP5mcg=",
"usagesDigest": "KXZUVR9ea29hTmhxC4+BG0pTXTijLpsFrDVraHG4OyU=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"eclipse_temurin_17_linux_amd64": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_pull",
"attributes": {
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/eclipse-temurin",
"identifier": "sha256:d286b5352d98777bbf727f54038b04f0145cd9b76ca83f38a67aa111d4303748",
"platform": "linux/amd64",
"target_name": "eclipse_temurin_17_linux_amd64",
"bazel_tags": []
}
},
"eclipse_temurin_17": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_alias",
"attributes": {
"target_name": "eclipse_temurin_17",
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/eclipse-temurin",
"identifier": "sha256:d286b5352d98777bbf727f54038b04f0145cd9b76ca83f38a67aa111d4303748",
"platforms": {
"@@platforms//cpu:x86_64": "@eclipse_temurin_17_linux_amd64"
},
"bzlmod_repository": "eclipse_temurin_17",
"reproducible": true
}
},
"ubuntu_24_04_linux_amd64": {
"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/amd64",
"target_name": "ubuntu_24_04_linux_amd64",
"bazel_tags": []
}
},
"ubuntu_24_04": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_alias",
"attributes": {
"target_name": "ubuntu_24_04",
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/ubuntu",
"identifier": "24.04",
"platforms": {
"@@platforms//cpu:x86_64": "@ubuntu_24_04_linux_amd64"
},
"bzlmod_repository": "ubuntu_24_04",
"reproducible": true
}
},
"alpine_linux_linux_amd64": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_pull",
"attributes": {
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/alpine",
"identifier": "3.21",
"platform": "linux/amd64",
"target_name": "alpine_linux_linux_amd64",
"bazel_tags": []
}
},
"alpine_linux": {
"bzlFile": "@@rules_oci~//oci/private:pull.bzl",
"ruleClassName": "oci_alias",
"attributes": {
"target_name": "alpine_linux",
"www_authenticate_challenges": {},
"scheme": "https",
"registry": "index.docker.io",
"repository": "library/alpine",
"identifier": "3.21",
"platforms": {
"@@platforms//cpu:x86_64": "@alpine_linux_linux_amd64"
},
"bzlmod_repository": "alpine_linux",
"reproducible": true
}
},
"oci_crane_darwin_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "darwin_amd64",
"crane_version": "v0.18.0"
}
},
"oci_crane_darwin_arm64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "darwin_arm64",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_arm64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_arm64",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_armv6": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_armv6",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_i386": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_i386",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_s390x": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_s390x",
"crane_version": "v0.18.0"
}
},
"oci_crane_linux_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "linux_amd64",
"crane_version": "v0.18.0"
}
},
"oci_crane_windows_armv6": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "windows_armv6",
"crane_version": "v0.18.0"
}
},
"oci_crane_windows_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "crane_repositories",
"attributes": {
"platform": "windows_amd64",
"crane_version": "v0.18.0"
}
},
"oci_crane_toolchains": {
"bzlFile": "@@rules_oci~//oci/private:toolchains_repo.bzl",
"ruleClassName": "toolchains_repo",
"attributes": {
"toolchain_type": "@rules_oci//oci:crane_toolchain_type",
"toolchain": "@oci_crane_{platform}//:crane_toolchain"
}
},
"oci_regctl_darwin_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "darwin_amd64"
}
},
"oci_regctl_darwin_arm64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "darwin_arm64"
}
},
"oci_regctl_linux_arm64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "linux_arm64"
}
},
"oci_regctl_linux_s390x": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "linux_s390x"
}
},
"oci_regctl_linux_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "linux_amd64"
}
},
"oci_regctl_windows_amd64": {
"bzlFile": "@@rules_oci~//oci:repositories.bzl",
"ruleClassName": "regctl_repositories",
"attributes": {
"platform": "windows_amd64"
}
},
"oci_regctl_toolchains": {
"bzlFile": "@@rules_oci~//oci/private:toolchains_repo.bzl",
"ruleClassName": "toolchains_repo",
"attributes": {
"toolchain_type": "@rules_oci//oci:regctl_toolchain_type",
"toolchain": "@oci_regctl_{platform}//:regctl_toolchain"
}
}
},
"moduleExtensionMetadata": {
"explicitRootModuleDirectDeps": [
"eclipse_temurin_17",
"eclipse_temurin_17_linux_amd64",
"ubuntu_24_04",
"ubuntu_24_04_linux_amd64",
"alpine_linux",
"alpine_linux_linux_amd64"
],
"explicitRootModuleDirectDevDeps": [],
"useAllRepos": "NO",
"reproducible": false
},
"recordedRepoMappingEntries": [
[
"aspect_bazel_lib~",
"bazel_tools",
"bazel_tools"
],
[
"bazel_features~",
"bazel_tools",
"bazel_tools"
],
[
"rules_oci~",
"aspect_bazel_lib",
"aspect_bazel_lib~"
],
[
"rules_oci~",
"bazel_features",
"bazel_features~"
],
[
"rules_oci~",
"bazel_skylib",
"bazel_skylib~"
]
]
}
},
"@@rules_scala~//scala/extensions:config.bzl%scala_config": {
"general": {
"bzlTransitiveDigest": "TdBxhkZTM7VU6teIFS+KoonKU7wmb5BL7leCWWx7yX8=",
@@ -1592,7 +1293,7 @@
},
"@@rules_scala~//scala/extensions:deps.bzl%scala_deps": {
"general": {
"bzlTransitiveDigest": "5SDZrXQHW6tI/VEw+La2OPOK4ZWm0LGTxnChXOXBCag=",
"bzlTransitiveDigest": "F2PMm61fmZ/IE+VSw1rigJ71hBDD7k3vqyYR1/GgXeA=",
"usagesDigest": "kwo8oolISmSSITnit4b4S0vBiUtHlHK0WLDUwScxmOg=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
@@ -5203,6 +4904,85 @@
]
]
}
},
"@@toolchains_llvm~//toolchain/extensions:llvm.bzl%llvm": {
"general": {
"bzlTransitiveDigest": "afRF0aFOIUrkYl3o040WQ606ep1qciEXzjnAxT3Kek8=",
"usagesDigest": "sYVuhiCAQehFTnGTv0bNtTBR4WorebpWBNxF0mRusyw=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"llvm_toolchain_llvm": {
"bzlFile": "@@toolchains_llvm~//toolchain:rules.bzl",
"ruleClassName": "llvm",
"attributes": {
"alternative_llvm_sources": [],
"auth_patterns": {},
"distribution": "auto",
"exec_arch": "",
"exec_os": "",
"libclang_rt": {},
"llvm_mirror": "",
"llvm_version": "20.1.2",
"llvm_versions": {},
"netrc": "",
"sha256": {},
"strip_prefix": {},
"urls": {}
}
},
"llvm_toolchain": {
"bzlFile": "@@toolchains_llvm~//toolchain:rules.bzl",
"ruleClassName": "toolchain",
"attributes": {
"absolute_paths": false,
"archive_flags": {},
"compile_flags": {},
"conly_flags": {},
"coverage_compile_flags": {},
"coverage_link_flags": {},
"cxx_builtin_include_directories": {},
"cxx_flags": {},
"cxx_standard": {},
"dbg_compile_flags": {},
"exec_arch": "",
"exec_os": "",
"extra_exec_compatible_with": {},
"extra_target_compatible_with": {},
"link_flags": {},
"link_libs": {},
"llvm_versions": {
"": "20.1.2"
},
"opt_compile_flags": {},
"opt_link_flags": {},
"stdlib": {},
"target_settings": {},
"unfiltered_compile_flags": {},
"toolchain_roots": {},
"sysroot": {}
}
}
},
"recordedRepoMappingEntries": [
[
"toolchains_llvm~",
"bazel_skylib",
"bazel_skylib~"
],
[
"toolchains_llvm~",
"bazel_tools",
"bazel_tools"
],
[
"toolchains_llvm~",
"toolchains_llvm",
"toolchains_llvm~"
]
]
}
}
}
}
-193
View File
@@ -1,193 +0,0 @@
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load", "oci_push")
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
#
# Shared utilities layer (busybox for nc, wget, etc.)
#
pkg_tar(
name = "busybox_layer",
srcs = ["@busybox_x86_64//file"],
package_dir = "/usr/local/bin",
remap_paths = {
"file/busybox": "busybox",
},
symlinks = {
"/usr/local/bin/nc": "busybox",
},
)
#
# Eagle Server Docker Image
#
# Build: bazel build //ci:eagle_server_image
# Load: bazel run //ci:eagle_server_load
# Push: bazel run //ci:eagle_server_push
#
# Package the deploy JAR
pkg_tar(
name = "eagle_server_jar_layer",
srcs = ["//src/main/scala/net/eagle0/eagle:eagle_server_deploy.jar"],
package_dir = "/app",
)
# Package the game resources needed at runtime
pkg_tar(
name = "eagle_resources_layer",
srcs = [
"//src/main/resources/net/eagle0/eagle:beasts",
"//src/main/resources/net/eagle0/eagle:game_parameters",
"//src/main/resources/net/eagle0/eagle:headshots",
"//src/main/resources/net/eagle0/eagle:heroes",
"//src/main/resources/net/eagle0/eagle:province_map",
"//src/main/resources/net/eagle0/eagle:settings",
],
package_dir = "/app/resources",
)
oci_image(
name = "eagle_server_image",
base = "@eclipse_temurin_17_linux_amd64",
entrypoint = [
"java",
"-Xmx4g",
"-XX:+UseG1GC",
"-jar",
"/app/eagle_server_deploy.jar",
],
env = {
"JAVA_OPTS": "-Xmx4g -XX:+UseG1GC",
},
exposed_ports = ["40032/tcp"],
tars = [
":busybox_layer",
":eagle_server_jar_layer",
":eagle_resources_layer",
],
workdir = "/app",
)
# Load into Docker locally: bazel run //ci:eagle_server_load
oci_load(
name = "eagle_server_load",
image = ":eagle_server_image",
repo_tags = ["eagle0/eagle-server:latest"],
)
# Push to DigitalOcean Container Registry
# Note: No remote_tags here - DigitalOcean converts OCI to Docker format,
# changing the digest and breaking oci_push's tag-by-digest logic.
# Tagging is handled in the CI workflow using crane copy/tag.
oci_push(
name = "eagle_server_push",
image = ":eagle_server_image",
repository = "registry.digitalocean.com/eagle0/eagle-server",
)
#
# Shardok Server Docker Image
#
# Build: bazel build //ci:shardok_server_image
# Load: bazel run //ci:shardok_server_load
# Push: bazel run //ci:shardok_server_push
#
# Package the Shardok binary
pkg_tar(
name = "shardok_binary_layer",
srcs = ["//src/main/cpp/net/eagle0/shardok:shardok-server"],
package_dir = "/app",
)
# Package the Shardok resources (battalion types, settings)
pkg_tar(
name = "shardok_resources_layer",
srcs = [
"//src/main/resources/net/eagle0/shardok:battalion_types",
"//src/main/resources/net/eagle0/shardok:settings",
],
package_dir = "/app/resources",
)
# Package the converted maps
pkg_tar(
name = "shardok_maps_layer",
srcs = ["//src/main/resources/net/eagle0/shardok/maps"],
package_dir = "/app/resources/maps",
)
oci_image(
name = "shardok_server_image",
base = "@ubuntu_24_04_linux_amd64",
entrypoint = ["/app/shardok-server"],
exposed_ports = [
"40042/tcp",
"40052/tcp",
],
tars = [
":busybox_layer",
":shardok_binary_layer",
":shardok_resources_layer",
":shardok_maps_layer",
],
workdir = "/app",
)
# Load into Docker locally: bazel run //ci:shardok_server_load
oci_load(
name = "shardok_server_load",
image = ":shardok_server_image",
repo_tags = ["eagle0/shardok-server:latest"],
)
# Push to DigitalOcean Container Registry
# Note: No remote_tags here - DigitalOcean converts OCI to Docker format,
# changing the digest and breaking oci_push's tag-by-digest logic.
# Tagging is handled in the CI workflow using crane copy/tag.
oci_push(
name = "shardok_server_push",
image = ":shardok_server_image",
repository = "registry.digitalocean.com/eagle0/shardok-server",
)
#
# Admin Server Docker Image (Go)
#
# Build: bazel build //ci:admin_server_image
# Load: bazel run //ci:admin_server_load
# Push: bazel run //ci:admin_server_push
#
# Package the Go admin binary (explicit Linux x86_64 target)
pkg_tar(
name = "admin_binary_layer",
srcs = ["//src/main/go/net/eagle0/admin_server:admin_server_linux_amd64"],
package_dir = "/app",
)
oci_image(
name = "admin_server_image",
base = "@alpine_linux_linux_amd64",
entrypoint = ["/app/admin_server_linux_amd64"],
exposed_ports = ["8080/tcp"],
tars = [
":busybox_layer",
":admin_binary_layer",
],
workdir = "/app",
)
# Load into Docker locally: bazel run //ci:admin_server_load
oci_load(
name = "admin_server_load",
image = ":admin_server_image",
repo_tags = ["eagle0/admin-server:latest"],
)
# Push to DigitalOcean Container Registry
oci_push(
name = "admin_server_push",
image = ":admin_server_image",
repository = "registry.digitalocean.com/eagle0/admin-server",
)
-118
View File
@@ -1,118 +0,0 @@
# Docker Compose for production deployment
#
# Local testing:
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:shardok_server_load
# Run: docker compose -f docker-compose.prod.yml up
#
# Production deployment:
# Run: docker compose -f docker-compose.prod.yml up -d
services:
eagle:
image: ${EAGLE_IMAGE:-registry.digitalocean.com/eagle0/eagle-server:latest}
container_name: eagle-server
command:
- "--gpt-model-name"
- "${GPT_MODEL_NAME:-gpt-5.1}"
- "--shardok-interface-remote-address"
- "shardok:40042"
ports:
- "40032:40032"
environment:
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
volumes:
- ./saves:/app/saves
depends_on:
- shardok
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 40032 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
shardok:
image: ${SHARDOK_IMAGE:-registry.digitalocean.com/eagle0/shardok-server:latest}
container_name: shardok-server
ports:
- "40042:40042"
- "40052:40052"
environment:
SHARDOK_RESOURCES_PATH: "/app/resources"
SHARDOK_MAPS_PATH: "/app/resources/maps"
SHARDOK_EAGLE_INTERFACE_ADDRESS: "0.0.0.0:40042"
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 40042 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
nginx:
image: nginx:alpine
container_name: nginx
ports:
- "443:443"
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./certbot/conf:/etc/letsencrypt:ro
- ./certbot/www:/var/www/certbot:ro
- ./auth:/etc/nginx/auth:ro
depends_on:
- eagle
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
admin:
image: ${ADMIN_IMAGE:-registry.digitalocean.com/eagle0/admin-server:latest}
container_name: admin-server
command:
- "--eagle-addr"
- "eagle:40032"
- "--http-port"
- "8080"
ports:
- "8080:8080"
depends_on:
- eagle
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:8080/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
certbot:
image: certbot/certbot
container_name: certbot
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
+31 -88
View File
@@ -197,86 +197,34 @@ Change APIs to vend Scala `GameState` and `ActionResultT` instead of proto versi
### Remaining Proto Usage in Actions
**Progress: 47 of 52 action files (90%) are fully protoless.**
The following actions still have proto usage, blocked by utility dependencies:
The following 5 actions still have proto usage:
| Action | Proto Usages | Blocker | Effort |
|--------|--------------|---------|--------|
| `ResolveBattleAction` | 24 | Shardok interface, complex battle logic | High |
| `PerformVassalCommandsPhaseAction` | 3 | `CommandChoiceHelpers` takes proto GameState | Medium |
| `EndHandleRiotsPhaseAction` | 2 | `CommandChoiceHelpers` takes proto GameState | Medium |
| `PerformVassalDefenseDecisionsAction` | 2 | `CommandChoiceHelpers` takes proto GameState | Medium |
| `EndVassalCommandsPhaseAction` | 1 | `CommandChoiceHelpers` takes proto GameState | Medium |
**Note:** `NewRoundAction` is now fully protoless after converting `ChronicleEventGenerator` to return Scala `ChronicleEvent` types directly.
**Deleted Dead Code:**
- `UnaffiliatedHeroMovedAction` - Was never called; `PerformUnaffiliatedHeroesAction.heroMovedResult` constructs `ActionResultC` directly
- `HeroBackstoryUpdateActionGenerator.fromGameState` - Dead method that converted proto to Scala; only `apply(GameState)` is used
**Note**: `PerformReconResolutionAction` and `EndBattleAftermathPhaseAction` are now fully protoless after:
1. Migrating `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` to use Scala `ProvinceView`
2. Adding Scala overload of `ProvinceViewFilter.withdrawnFromProvinceView`
| Action | Proto Usage | Blocker |
|--------|-------------|---------|
| `EndBattleAftermathPhaseAction` | 1 `toProto` call | `ProvinceViewFilter` needs Scala types |
| `NewRoundAction` | 1 `fromProto` call | `ChronicleEventGenerator` returns proto |
| `EndHandleRiotsPhaseAction` | 1 `toProto` call | `CommandChoiceHelpers` takes proto GameState |
| `PerformVassalCommandsPhaseAction` | 1 `toProto` call | `CommandChoiceHelpers` takes proto GameState |
| `PerformVassalDefenseDecisionsAction` | 1 `toProto` call | `CommandChoiceHelpers` takes proto GameState |
| `EndVassalCommandsPhaseAction` | 1 `toProto` call | `CommandChoiceHelpers` takes proto GameState |
| `PerformReconResolutionAction` | Uses `ProvinceViewFilter` | `ProvinceViewFilter` needs Scala types |
| `ResolveBattleAction` | Heavy proto usage | Large refactor needed |
### Estimated Effort (Remaining)
| Component | Lines | Complexity | Blocks |
|-----------|-------|------------|--------|
| `CommandChoiceHelpers` to Scala | ~2000 | High | 4 vassal actions |
| `ResolveBattleAction` refactor | ~500 | High | 1 action (complex) |
| History API updates | ~100 | Low | - |
| **Total Remaining** | **~2600** | | |
**Completed:**
- `ChronicleEventGenerator` converted to return Scala `ChronicleEvent` types directly
### CommandChoiceHelpers Migration Status
Several command selectors have already been converted to use Scala types:
| File | Status | Notes |
|------|--------|-------|
| `AttackCommandChooser.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
| `AlmsCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
| `FoodConsumptionUtils.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `RoundPhase` |
| `MarchSuppliesHelpers.scala` | ✅ **Protoless** | Uses `BattalionT` |
| `CombatUnitSelector.scala` | ✅ **Protoless** | Uses `HeroT`, `BattalionT`, `BattalionType` |
| `ExpandCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `FactionT` |
| `ImproveCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `HeroT` |
| `OrganizeCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `BattalionT`, `BattalionType` |
| `RansomOfferHelpers.scala` | ✅ **Protoless** | Uses Scala `GameState`, `FactionT` |
| `CommandChoiceHelpers.scala` | ❌ Proto | Main entry point, converts to Scala when calling converted selectors |
| `ProvinceGoldSurplusCalculator.scala` | **Partial** | Has both Scala and proto overloads |
| Other selectors | ❌ Proto | Various proto dependencies |
**Pattern**: `CommandChoiceHelpers` currently uses `GameStateConverter.fromProto(gameState)` when calling already-converted selectors like `AlmsCommandSelector` and `AttackCommandChooser`. This allows incremental migration.
**Next Steps**:
1. ~~Convert `ExpandCommandSelector` to Scala types~~ ✅ Done
2. ~~Convert `ImproveCommandSelector` to Scala types~~ ✅ Done
3. ~~Convert `OrganizeCommandSelector` to Scala types~~ ✅ Done (PR #4812)
4. ~~Convert `RansomOfferHelpers` to Scala types~~ ✅ Done (PR #4821)
5. Convert remaining selectors one at a time
6. Update `CommandChoiceHelpers` to accept Scala `GameState` once all selectors are converted
### Progress Summary
| Metric | Value |
|--------|-------|
| Action files fully protoless | 47 / 52 (90%) |
| Proto usages in remaining actions | 32 total |
| Biggest blocker | `ResolveBattleAction` (24 usages) |
| Second biggest blocker | `CommandChoiceHelpers` (blocks 4 actions) |
| Component | Lines | Complexity |
|-----------|-------|------------|
| `ProvinceViewFilter` to Scala | ~150 | Medium |
| `CommandChoiceHelpers` to Scala | ~2000 | High |
| `ChronicleEventGenerator` to Scala | ~400 | Medium |
| History API updates | ~100 | Low |
| **Total Remaining** | **~2650** | |
### Validation
- [x] `ActionResultApplier` created and tested
- [x] `RandomStateSequencer` threads Scala GameState throughout
- [x] `RoundPhaseAdvancer` uses T-types internally
- [x] `ProvinceViewFilter` has Scala overload for server-side use (PR #4752)
- [x] `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` use Scala `ProvinceView`
- [x] `ProvinceViewFilter.withdrawnFromProvinceView` has Scala overload
- [ ] `ProvinceViewFilter` faction-filtered views use Scala types
- [ ] `ProvinceViewFilter` uses Scala types
- [ ] `CommandChoiceHelpers` uses Scala types
- [ ] History APIs vend Scala types
- [ ] No `ActionResultProtoConverter.toProto()` calls except at persistence/gRPC boundaries
@@ -300,27 +248,22 @@ Remove remaining direct proto imports from utility classes.
| `BattalionTypeLoader.scala` | Keep proto for file loading, convert immediately after |
| `BeastUtils.scala` | **Complete** - now uses Scala `BeastInfo` only |
### View Filters (Partially Complete)
### View Filters (Blocking Full Deproto)
The view filter utilities now have Scala overloads for server-side use:
The `ProvinceViewFilter` utility currently works entirely with proto types, blocking full deproto of actions that generate province views:
| File | Status | Notes |
|------|--------|-------|
| `ProvinceViewFilter.scala` | **Partial** | `filteredProvinceView(ProvinceT, ScalaGameState)` added (PR #4752) |
| `ArmyFilter.scala` | **Partial** | `filterArmy(ScalaArmy, Map[BattalionId, BattalionT], Option[FactionId])` added |
| `BattalionViewFilter.scala` | **Complete** | Uses Scala `BattalionT` throughout |
| `GameStateViewFilter.scala` | Pending | Uses proto types throughout |
| `GameStateViewDiffer.scala` | Pending | Works with view protos |
| File | Issue | Needed |
|------|-------|--------|
| `ProvinceViewFilter.scala` | Takes proto `Province`/`GameState`, returns proto `ProvinceView` | Scala `ProvinceViewT` model |
| `GameStateViewFilter.scala` | Uses proto types throughout | Depends on `ProvinceViewT` |
| `GameStateViewDiffer.scala` | Works with view protos | Depends on `ProvinceViewT` |
**Unblocked Actions** (PR #4752):
- `EndBattleAftermathPhaseAction` - can now use `filteredProvinceView(province, scalaGameState)`
- `PerformReconResolutionAction` - can now use Scala overload
- `GameStateFactionExtensions` - can now use `updatedReconnedProvinces` with Scala types
**Blocked Actions**:
- `EndBattleAftermathPhaseAction` - uses `ProvinceViewFilter` for `revelationChange`, requires lazy proto conversion
- `PerformReconResolutionAction` - uses `ProvinceViewFilter` for reconned provinces
- `GameStateFactionExtensions` - uses `ProvinceViewFilter` for `updatedReconnedProvinces`
**Remaining Work**:
- Faction-filtered `filteredProvinceView(Province, GameState, FactionId)` still uses proto types
- `withdrawnFromProvinceView` still uses proto types
- These are needed for client-facing views with visibility restrictions
**Solution**: Create Scala `ProvinceViewT` (and possibly `ProvinceViewC`) that mirrors the proto `ProvinceView`. Then create a protoless `ProvinceViewFilter` that operates on Scala types. The proto version can delegate to the Scala version + convert, or we maintain both during transition.
---
-189
View File
@@ -1,189 +0,0 @@
# Discord + Google OAuth Implementation Plan
## Overview
Replace HTTP Basic Auth with OAuth 2.0 (Discord + Google) for Eagle0. Users authenticate via system browser, receive JWT tokens, and choose their own display names.
## Architecture
```
Unity Client Eagle Server
| |
| 1. Click "Login with Discord/Google" |
| -------------------------------------------------> |
| GetOAuthUrl(provider) -> auth_url + state |
| |
| 2. Open system browser -> OAuth consent |
| 3. User authenticates with provider |
| 4. Redirect to eagle0://auth/callback?code=xxx |
| |
| 5. ExchangeCode(code, state) |
| -------------------------------------------------> |
| Exchange code with provider |
| Fetch user info (id, email, avatar) |
| Create/update user record |
| Issue JWT + refresh token |
| <------------------------------------------------- |
| (jwt, refresh_token, user_info, is_new_user) |
| |
| 6. [If new user] SetDisplayName(name) |
| -------------------------------------------------> |
| |
| 7. Subsequent gRPC calls |
| Authorization: Bearer <jwt> |
| -------------------------------------------------> |
```
## Key Design Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| OAuth flow | System browser + deep link | Secure, supports password managers |
| Code exchange | Eagle server directly | No separate auth service needed |
| JWT signing | RS256 (asymmetric) | Future flexibility for token verification |
| User storage | Protobuf file via Persister | Consistent with existing patterns |
| Token expiry | 7-day access, 30-day refresh | Balance security and gaming UX |
## Implementation Phases
### Phase 1: Proto Definitions & Infrastructure
**New files:**
- `src/main/protobuf/net/eagle0/eagle/api/auth.proto` - Auth API messages
- `src/main/protobuf/net/eagle0/eagle/internal/user.proto` - User storage schema
**Key proto messages:**
```protobuf
// API
GetOAuthUrlRequest/Response // Get OAuth URL to open in browser
ExchangeCodeRequest/Response // Exchange auth code for JWT
SetDisplayNameRequest/Response // Set user's display name
RefreshTokenRequest/Response // Refresh expired access token
// Internal storage
User // user_id, display_name, oauth_identities
UserDatabase // All users + indexes for lookup
```
### Phase 2: Eagle Server Auth Services
**New Scala files:**
- `src/main/scala/net/eagle0/eagle/auth/OAuthConfig.scala` - Discord/Google config from env vars
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - JWT creation/validation (RS256)
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD, display name validation
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth code exchange
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC service implementation
**Modify:**
- `src/main/scala/net/eagle0/eagle/service/AuthorizationInterceptor.scala`
- Replace Basic Auth parsing with JWT validation
- Skip auth for public endpoints (GetOAuthUrl, ExchangeCode, RefreshToken)
- `src/main/scala/net/eagle0/eagle/service/AuthorizationUtils.scala`
- Change context keys from `userName` to `userId` + `displayName`
- `src/main/scala/net/eagle0/eagle/service/Main.scala`
- Wire up new auth services and JWT key loading
### Phase 3: Unity Client OAuth Flow
**New C# files:**
- `Assets/Auth/OAuthManager.cs` - OAuth flow + deep link handling
- `Assets/Auth/TokenStorage.cs` - Secure token persistence
- `Assets/Auth/AuthClient.cs` - gRPC client for auth service
**Modify:**
- `Assets/EagleConnection.cs`
- Replace `AuthInterceptor` (Basic Auth) with `JwtAuthInterceptor` (Bearer token)
- `Assets/ConnectionHandler/ConnectionHandler.cs`
- Replace username/password UI with Discord/Google login buttons
- Add display name setup flow for new users
### Phase 4: Platform Configuration
**Deep link registration:**
- iOS: Add `eagle0://` to CFBundleURLSchemes in Info.plist
- Android: Add intent-filter for `eagle0://auth` in AndroidManifest.xml
- Desktop: Register URL scheme (Windows registry / macOS plist)
**OAuth provider setup:**
1. Discord Developer Portal: Create app, add redirect URI `eagle0://auth/callback`
2. Google Cloud Console: Create OAuth client, add redirect URI
**Environment variables (server):**
```
DISCORD_CLIENT_ID
DISCORD_CLIENT_SECRET
GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET
JWT_PRIVATE_KEY_PATH
JWT_PUBLIC_KEY_PATH
```
### Phase 5: Testing
**Unit tests:**
- `JwtServiceSpec.scala` - Token creation/validation
- `UserServiceSpec.scala` - Display name validation, uniqueness
- `OAuthServiceSpec.scala` - OAuth flow with mocked providers
**Integration tests:**
- Full OAuth flow with mock provider
- JWT validation in AuthorizationInterceptor
- gRPC calls with valid/invalid tokens
**Manual testing:**
- [ ] Discord login (Windows, macOS)
- [ ] Google login (Windows, macOS)
- [ ] Deep link callback works
- [ ] Display name validation
- [ ] Session persistence across restarts
- [ ] Token refresh
## Files Summary
### Create
| File | Purpose |
|------|---------|
| `src/main/protobuf/net/eagle0/eagle/api/auth.proto` | Auth API definitions |
| `src/main/protobuf/net/eagle0/eagle/internal/user.proto` | User storage schema |
| `src/main/scala/net/eagle0/eagle/auth/OAuthConfig.scala` | Provider config |
| `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` | JWT handling |
| `src/main/scala/net/eagle0/eagle/auth/UserService.scala` | User management |
| `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` | OAuth flow |
| `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` | gRPC service |
| `Assets/Auth/OAuthManager.cs` | Unity OAuth manager |
| `Assets/Auth/TokenStorage.cs` | Token storage |
| `Assets/Auth/AuthClient.cs` | Auth gRPC client |
### Modify
| File | Changes |
|------|---------|
| `AuthorizationInterceptor.scala` | Basic Auth -> JWT validation |
| `AuthorizationUtils.scala` | userName -> userId + displayName |
| `Main.scala` | Wire auth services |
| `EagleConnection.cs` | AuthInterceptor -> JwtAuthInterceptor |
| `ConnectionHandler.cs` | Login UI -> OAuth buttons + display name |
### Delete
- nginx htpasswd configuration (no longer needed)
## Security Considerations
1. **State parameter** - CSRF protection in OAuth flow
2. **PKCE** - Consider adding for mobile (enhancement)
3. **Secure storage** - Use Keychain (iOS) / Keystore (Android) for tokens
4. **Token refresh** - 7-day access tokens with 30-day refresh
5. **Rate limiting** - Limit login attempts per IP
## Dependencies to Add
**Scala (MODULE.bazel):**
- JWT library (e.g., `jwt-scala` or `nimbus-jose-jwt`)
- HTTP client (e.g., `sttp` for OAuth requests)
**Unity:**
- Deep linking is built-in (Unity 2021+)
- No additional packages required
## Rollback Plan
Keep Basic Auth code in a feature branch. Both auth methods can coexist during transition via feature flag if needed.
File diff suppressed because it is too large Load Diff
+2 -54
View File
@@ -1,7 +1,7 @@
{
"__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL",
"__INPUT_ARTIFACTS_HASH": -1064460283,
"__RESOLVED_ARTIFACTS_HASH": -1574144850,
"__INPUT_ARTIFACTS_HASH": 289080209,
"__RESOLVED_ARTIFACTS_HASH": -131178107,
"conflict_resolution": {
"com.google.guava:failureaccess:1.0.1": "com.google.guava:failureaccess:1.0.2",
"com.squareup.okio:okio:2.10.0": "com.squareup.okio:okio:3.6.0",
@@ -48,12 +48,6 @@
},
"version": "2.12.7"
},
"com.github.stephenc.jcip:jcip-annotations": {
"shasums": {
"jar": "4fccff8382aafc589962c4edb262f6aa595e34f1e11e61057d1c6a96e8fc7323"
},
"version": "1.0-1"
},
"com.google.android:annotations": {
"shasums": {
"jar": "ba734e1e84c09d615af6a09d33034b4f0442f8772dec120efb376d86a565ae15"
@@ -162,12 +156,6 @@
},
"version": "1.4.2"
},
"com.nimbusds:nimbus-jose-jwt": {
"shasums": {
"jar": "12ae4a3a260095d7aeba2adea7ae396e8b9570db8b7b409e09a824c219cc0444"
},
"version": "9.37.3"
},
"com.squareup.okhttp3:okhttp": {
"shasums": {
"jar": "b1050081b14bb7a3a7e55a4d3ef01b5dcfabc453b4573a4fc019767191d5f4e0"
@@ -822,9 +810,6 @@
"org.checkerframework:checker-qual",
"org.ow2.asm:asm"
],
"com.nimbusds:nimbus-jose-jwt": [
"com.github.stephenc.jcip:jcip-annotations"
],
"com.squareup.okhttp3:okhttp": [
"com.squareup.okio:okio",
"org.jetbrains.kotlin:kotlin-stdlib-jdk8"
@@ -1370,9 +1355,6 @@
"com.fasterxml.jackson.databind.type",
"com.fasterxml.jackson.databind.util"
],
"com.github.stephenc.jcip:jcip-annotations": [
"net.jcip.annotations"
],
"com.google.android:annotations": [
"android.annotation"
],
@@ -1518,38 +1500,6 @@
"com.google.truth:truth": [
"com.google.common.truth"
],
"com.nimbusds:nimbus-jose-jwt": [
"com.nimbusds.jose",
"com.nimbusds.jose.crypto",
"com.nimbusds.jose.crypto.bc",
"com.nimbusds.jose.crypto.factories",
"com.nimbusds.jose.crypto.impl",
"com.nimbusds.jose.crypto.opts",
"com.nimbusds.jose.crypto.utils",
"com.nimbusds.jose.jca",
"com.nimbusds.jose.jwk",
"com.nimbusds.jose.jwk.gen",
"com.nimbusds.jose.jwk.source",
"com.nimbusds.jose.mint",
"com.nimbusds.jose.proc",
"com.nimbusds.jose.produce",
"com.nimbusds.jose.shaded.gson",
"com.nimbusds.jose.shaded.gson.annotations",
"com.nimbusds.jose.shaded.gson.internal",
"com.nimbusds.jose.shaded.gson.internal.bind",
"com.nimbusds.jose.shaded.gson.internal.bind.util",
"com.nimbusds.jose.shaded.gson.internal.reflect",
"com.nimbusds.jose.shaded.gson.internal.sql",
"com.nimbusds.jose.shaded.gson.reflect",
"com.nimbusds.jose.shaded.gson.stream",
"com.nimbusds.jose.util",
"com.nimbusds.jose.util.cache",
"com.nimbusds.jose.util.events",
"com.nimbusds.jose.util.health",
"com.nimbusds.jwt",
"com.nimbusds.jwt.proc",
"com.nimbusds.jwt.util"
],
"com.squareup.okhttp3:okhttp": [
"okhttp3",
"okhttp3.internal",
@@ -2387,7 +2337,6 @@
"com.fasterxml.jackson.core:jackson-annotations",
"com.fasterxml.jackson.core:jackson-core",
"com.fasterxml.jackson.core:jackson-databind",
"com.github.stephenc.jcip:jcip-annotations",
"com.google.android:annotations",
"com.google.api.grpc:proto-google-common-protos",
"com.google.auth:google-auth-library-credentials",
@@ -2406,7 +2355,6 @@
"com.google.protobuf:protobuf-java",
"com.google.re2j:re2j",
"com.google.truth:truth",
"com.nimbusds:nimbus-jose-jwt",
"com.squareup.okhttp3:okhttp",
"com.squareup.okhttp3:okhttp-sse",
"com.squareup.okhttp:okhttp",
-100
View File
@@ -1,100 +0,0 @@
events {
worker_connections 1024;
}
http {
# Logging
log_format grpc_json escape=json '{'
'"time":"$time_iso8601",'
'"client":"$remote_addr",'
'"uri":"$uri",'
'"status":$status,'
'"grpc_status":"$sent_http_grpc_status",'
'"request_time":$request_time,'
'"upstream_time":"$upstream_response_time"'
'}';
access_log /var/log/nginx/access.log grpc_json;
error_log /var/log/nginx/error.log warn;
# Rate limiting zone
limit_req_zone $binary_remote_addr zone=grpc_limit:10m rate=100r/s;
# Docker DNS resolver - re-resolve hostnames every 10s
# This prevents stale IP caching when containers restart
resolver 127.0.0.11 valid=10s ipv6=off;
# Upstream for Eagle gRPC server
upstream eagle_grpc {
server eagle:40032;
keepalive 100;
}
# HTTP server for Let's Encrypt challenge and redirect
server {
listen 80;
server_name prod.eagle0.net;
# Let's Encrypt challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect all other HTTP to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS server for gRPC
server {
listen 443 ssl;
http2 on;
server_name prod.eagle0.net;
# SSL certificates (managed by certbot)
ssl_certificate /etc/letsencrypt/live/prod.eagle0.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/prod.eagle0.net/privkey.pem;
# SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# gRPC proxy for Eagle service
location /net.eagle0.eagle.api.Eagle {
# Rate limiting
limit_req zone=grpc_limit burst=50 nodelay;
# gRPC proxy
grpc_pass grpc://eagle_grpc;
# Timeouts for long-running streams
grpc_read_timeout 1200s;
grpc_send_timeout 1200s;
grpc_socket_keepalive on;
# Error handling
error_page 502 = /error502grpc;
}
# Health check endpoint
location /health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
# gRPC error handling
location = /error502grpc {
internal;
default_type application/grpc;
add_header grpc-status 14;
add_header grpc-message "unavailable";
return 204;
}
}
}
-473
View File
@@ -1,473 +0,0 @@
#!/bin/bash
#
# generate_changelog.sh
#
# Generates a weekly changelog from merged PRs, uses Claude to create a synopsis,
# and sends an HTML email via Fastmail JMAP API.
#
# Usage: ./scripts/generate_changelog.sh [--dry-run]
#
# Configuration files (in ~/.config/eagle0/):
# fastmail_token - API token (required)
# changelog_recipient - Email addresses, one per line (optional, defaults to sender)
#
# To set up:
# mkdir -p ~/.config/eagle0
# echo 'your-token' > ~/.config/eagle0/fastmail_token
# chmod 600 ~/.config/eagle0/fastmail_token
#
# # Optional: configure recipients (one per line, # for comments)
# cat > ~/.config/eagle0/changelog_recipient << EOF
# alice@example.com
# bob@example.com
# EOF
#
# The script tracks its last run using a git tag 'changelog-last-run'.
# On first run (no tag), it defaults to the previous Friday at 4pm.
set -euo pipefail
# Ensure homebrew binaries are in PATH
export PATH="/opt/homebrew/bin:$PATH"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TAG_NAME="changelog-last-run"
DRY_RUN=false
FASTMAIL_API="https://api.fastmail.com/jmap/api/"
CONFIG_DIR="$HOME/.config/eagle0"
TOKEN_FILE="$CONFIG_DIR/fastmail_token"
RECIPIENT_FILE="$CONFIG_DIR/changelog_recipient"
# Load API token from file or environment
load_api_token() {
# Environment variable takes precedence
if [[ -n "${FASTMAIL_API_TOKEN:-}" ]]; then
return 0
fi
# Try loading from config file
if [[ -f "$TOKEN_FILE" ]]; then
FASTMAIL_API_TOKEN=$(cat "$TOKEN_FILE" | tr -d '[:space:]')
if [[ -n "$FASTMAIL_API_TOKEN" ]]; then
echo "Loaded API token from $TOKEN_FILE"
export FASTMAIL_API_TOKEN
return 0
fi
fi
return 1
}
# Load recipient emails from config file (one per line)
# Returns JSON array fragment like: {"email": "a@b.com"}, {"email": "c@d.com"}
load_recipients_json() {
local recipients=""
if [[ -f "$RECIPIENT_FILE" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
# Skip empty lines and comments
line=$(echo "$line" | tr -d '[:space:]')
[[ -z "$line" || "$line" == \#* ]] && continue
if [[ -n "$recipients" ]]; then
recipients="$recipients, "
fi
recipients="$recipients{\"email\": \"$line\"}"
done < "$RECIPIENT_FILE"
fi
echo "$recipients"
}
# Get human-readable list of recipients
load_recipients_display() {
if [[ -f "$RECIPIENT_FILE" ]]; then
grep -v '^#' "$RECIPIENT_FILE" | grep -v '^[[:space:]]*$' | tr '\n' ', ' | sed 's/, $//'
fi
}
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--dry-run)
DRY_RUN=true
shift
;;
*)
echo "Unknown option: $1"
echo "Usage: $0 [--dry-run]"
exit 1
;;
esac
done
cd "$REPO_ROOT"
# Get the cutoff date - either from tag or previous Friday 4pm
get_cutoff_date() {
# Try to get the date from the tag
if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then
# Get the commit date of the tagged commit
git log -1 --format="%aI" "$TAG_NAME"
else
# Calculate previous Friday at 4pm
# Get current day of week (1=Monday, 7=Sunday)
local dow=$(date +%u)
local days_since_friday
if [[ $dow -ge 5 ]]; then
# Friday (5), Saturday (6), or Sunday (7)
days_since_friday=$((dow - 5))
else
# Monday (1) through Thursday (4)
days_since_friday=$((dow + 2))
fi
# Get previous Friday at 4pm in ISO format
if [[ "$(uname)" == "Darwin" ]]; then
date -v-"${days_since_friday}d" -v16H -v0M -v0S +"%Y-%m-%dT%H:%M:%S%z"
else
date -d "$days_since_friday days ago 16:00:00" --iso-8601=seconds
fi
fi
}
# Fetch merged PRs since the cutoff date
fetch_merged_prs() {
local since_date="$1"
local output_file="$2"
echo "Fetching PRs merged since: $since_date"
# Use gh to search for merged PRs
gh pr list \
--state merged \
--base main \
--json number,title,body,mergedAt,author \
--jq ".[] | select(.mergedAt >= \"$since_date\")" \
> "$output_file.json"
# Format the output nicely
echo "# Merged PRs since $since_date" > "$output_file"
echo "" >> "$output_file"
# Process each PR
jq -r '
"## PR #\(.number): \(.title)\n" +
"Author: \(.author.login)\n" +
"Merged: \(.mergedAt)\n\n" +
"### Description\n" +
(.body // "(No description)") +
"\n\n---\n"
' "$output_file.json" >> "$output_file"
# Count PRs
local pr_count=$(jq -s 'length' "$output_file.json")
echo "Found $pr_count merged PRs"
rm -f "$output_file.json"
if [[ $pr_count -eq 0 ]]; then
echo "No PRs found since $since_date"
return 1
fi
return 0
}
# Generate synopsis using Claude
generate_synopsis() {
local input_file="$1"
local output_file="$2"
echo "Generating synopsis with Claude..."
# Create a prompt file to avoid shell escaping issues
local prompt_file="/tmp/eagle0_prompt_$$.txt"
# Get repo URL for PR links
local repo_url=$(gh repo view --json url -q '.url')
cat > "$prompt_file" <<PROMPT_HEADER
You are summarizing changes for a weekly engineering update email.
Read the following list of merged PRs and create a concise synopsis grouped by theme/feature/area of the codebase.
Structure:
1. <h1> title (e.g., "Eagle0 Weekly Update")
2. <h2>BLUF</h2> (Bottom Line Up Front) - A short prose paragraph (2-4 sentences) highlighting the 1-3 most important changes this week and what to look for when testing. This should be conversational and help readers quickly understand what matters most.
3. Synopsis sections (<h2> headings with bullet point summaries)
4. <hr> divider
5. <h2>PR Details</h2> with the same groupings, but smaller (<h3> headings) and listing PR links
- Format each PR as: <a href="${repo_url}/pull/NUMBER">#NUMBER</a>: Title
Guidelines for the SYNOPSIS sections:
- Group related changes together under clear headings (use <h2> tags)
- Use bullet points (<ul><li>) for individual changes
- Highlight any significant new features, breaking changes, or important fixes
- Keep the tone professional but accessible
- Don't include PR numbers in the synopsis - focus on what changed and why it matters
IMPORTANT: Output valid HTML that can be used directly in an email body. Do NOT wrap in \`\`\`html code blocks - just output the raw HTML.
Here are the merged PRs:
PROMPT_HEADER
cat "$input_file" >> "$prompt_file"
echo "" >> "$prompt_file"
echo "Generate the synopsis now:" >> "$prompt_file"
# Use Claude CLI to generate the synopsis, wrapped in proper HTML with charset
local raw_output="/tmp/eagle0_raw_$$.html"
cat "$prompt_file" | claude --print > "$raw_output"
# Wrap in HTML document with UTF-8 charset
cat > "$output_file" <<'HTML_HEAD'
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
HTML_HEAD
cat "$raw_output" >> "$output_file"
echo "</body></html>" >> "$output_file"
rm -f "$prompt_file" "$raw_output"
echo "Synopsis generated at: $output_file"
}
# Get Fastmail session info (account ID, identity ID, drafts mailbox ID)
get_fastmail_session() {
echo "Fetching Fastmail session info..." >&2
# Get session
local session=$(curl -s \
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
"https://api.fastmail.com/jmap/session")
# Extract account ID (first account)
FASTMAIL_ACCOUNT_ID=$(echo "$session" | jq -r '.primaryAccounts["urn:ietf:params:jmap:mail"]')
if [[ -z "$FASTMAIL_ACCOUNT_ID" || "$FASTMAIL_ACCOUNT_ID" == "null" ]]; then
echo "Error: Could not get Fastmail account ID. Check your API token." >&2
return 1
fi
echo "Account ID: $FASTMAIL_ACCOUNT_ID" >&2
# Get identity ID
local identity_response=$(curl -s \
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST \
-d "{
\"using\": [\"urn:ietf:params:jmap:core\", \"urn:ietf:params:jmap:mail\", \"urn:ietf:params:jmap:submission\"],
\"methodCalls\": [
[\"Identity/get\", {\"accountId\": \"$FASTMAIL_ACCOUNT_ID\"}, \"0\"]
]
}" \
"$FASTMAIL_API")
FASTMAIL_IDENTITY_ID=$(echo "$identity_response" | jq -r '.methodResponses[0][1].list[0].id')
FASTMAIL_FROM_EMAIL=$(echo "$identity_response" | jq -r '.methodResponses[0][1].list[0].email')
if [[ -z "$FASTMAIL_IDENTITY_ID" || "$FASTMAIL_IDENTITY_ID" == "null" ]]; then
echo "Error: Could not get Fastmail identity ID." >&2
return 1
fi
echo "Identity ID: $FASTMAIL_IDENTITY_ID (${FASTMAIL_FROM_EMAIL})" >&2
# Get drafts mailbox ID
local mailbox_response=$(curl -s \
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST \
-d "{
\"using\": [\"urn:ietf:params:jmap:core\", \"urn:ietf:params:jmap:mail\"],
\"methodCalls\": [
[\"Mailbox/query\", {\"accountId\": \"$FASTMAIL_ACCOUNT_ID\", \"filter\": {\"role\": \"drafts\"}}, \"0\"]
]
}" \
"$FASTMAIL_API")
FASTMAIL_DRAFTS_ID=$(echo "$mailbox_response" | jq -r '.methodResponses[0][1].ids[0]')
if [[ -z "$FASTMAIL_DRAFTS_ID" || "$FASTMAIL_DRAFTS_ID" == "null" ]]; then
echo "Error: Could not get Fastmail drafts mailbox ID." >&2
return 1
fi
echo "Drafts mailbox ID: $FASTMAIL_DRAFTS_ID" >&2
return 0
}
# Send email via Fastmail JMAP API
send_email_fastmail() {
local synopsis_file="$1"
local recipients_json="$2" # JSON array fragment: {"email": "a@b.com"}, {"email": "c@d.com"}
local subject="Eagle0 Weekly Changelog - $(date +%Y-%m-%d)"
local html_body=$(cat "$synopsis_file" | jq -Rs .)
echo "Sending email via Fastmail JMAP API..."
# Create the email and send it in one request
local response=$(curl -s \
-H "Authorization: Bearer $FASTMAIL_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST \
-d "{
\"using\": [
\"urn:ietf:params:jmap:core\",
\"urn:ietf:params:jmap:mail\",
\"urn:ietf:params:jmap:submission\"
],
\"methodCalls\": [
[\"Email/set\", {
\"accountId\": \"$FASTMAIL_ACCOUNT_ID\",
\"create\": {
\"draft\": {
\"from\": [{\"email\": \"$FASTMAIL_FROM_EMAIL\"}],
\"to\": [$recipients_json],
\"subject\": \"$subject\",
\"mailboxIds\": {\"$FASTMAIL_DRAFTS_ID\": true},
\"keywords\": {\"\$draft\": true},
\"htmlBody\": [{\"partId\": \"body\", \"type\": \"text/html\"}],
\"bodyValues\": {
\"body\": {
\"charset\": \"utf-8\",
\"value\": $html_body
}
}
}
}
}, \"0\"],
[\"EmailSubmission/set\", {
\"accountId\": \"$FASTMAIL_ACCOUNT_ID\",
\"onSuccessDestroyEmail\": [\"#sendIt\"],
\"create\": {
\"sendIt\": {
\"emailId\": \"#draft\",
\"identityId\": \"$FASTMAIL_IDENTITY_ID\"
}
}
}, \"1\"]
]
}" \
"$FASTMAIL_API")
# Check for errors
local error=$(echo "$response" | jq -r '.methodResponses[0][1].notCreated.draft.description // empty')
if [[ -n "$error" ]]; then
echo "Error creating email: $error" >&2
echo "Full response: $response" >&2
return 1
fi
local send_error=$(echo "$response" | jq -r '.methodResponses[1][1].notCreated.sendIt.description // empty')
if [[ -n "$send_error" ]]; then
echo "Error sending email: $send_error" >&2
echo "Full response: $response" >&2
return 1
fi
echo "Email sent successfully"
}
# Update the tag to mark this run
update_tag() {
echo "Updating $TAG_NAME tag..."
# Delete existing tag if present
git tag -d "$TAG_NAME" 2>/dev/null || true
git push origin --delete "$TAG_NAME" 2>/dev/null || true
# Create new tag at HEAD
git tag "$TAG_NAME"
git push origin "$TAG_NAME"
echo "Tag updated to current HEAD"
}
# Main
main() {
echo "=== Eagle0 Weekly Changelog Generator ==="
echo ""
# Load API token (only required for actual send)
if [[ "$DRY_RUN" != "true" ]]; then
if ! load_api_token; then
echo "Error: No Fastmail API token found."
echo ""
echo "To create a token:"
echo "1. Go to Fastmail Settings -> Password & Security -> API tokens"
echo "2. Create a new token with 'Email submission' scope"
echo "3. Save it using one of these methods:"
echo ""
echo " Option A (recommended): Store in config file"
echo " mkdir -p ~/.config/eagle0"
echo " echo 'your-token' > ~/.config/eagle0/fastmail_token"
echo " chmod 600 ~/.config/eagle0/fastmail_token"
echo ""
echo " Option B: Set environment variable"
echo " export FASTMAIL_API_TOKEN='your-token'"
exit 1
fi
fi
# Get cutoff date
local cutoff_date=$(get_cutoff_date)
echo "Cutoff date: $cutoff_date"
# Create temp files
local pr_file="/tmp/eagle0_prs_$(date +%s).md"
local synopsis_file="/tmp/eagle0_synopsis_$(date +%s).html"
# Fetch PRs
if ! fetch_merged_prs "$cutoff_date" "$pr_file"; then
echo "No changes to report. Exiting."
exit 0
fi
echo ""
echo "PR details saved to: $pr_file"
# Generate synopsis
generate_synopsis "$pr_file" "$synopsis_file"
if [[ "$DRY_RUN" == "true" ]]; then
echo ""
echo "=== DRY RUN - Synopsis content ==="
cat "$synopsis_file"
echo ""
echo "=== DRY RUN - Skipping email send and tag update ==="
else
# Get Fastmail session info
if ! get_fastmail_session; then
echo "Failed to get Fastmail session info. Exiting."
exit 1
fi
# Determine recipients (from config file, or default to sender)
local recipients_json=$(load_recipients_json)
if [[ -z "$recipients_json" ]]; then
recipients_json="{\"email\": \"$FASTMAIL_FROM_EMAIL\"}"
echo "No recipients configured, sending to self ($FASTMAIL_FROM_EMAIL)"
else
local recipients_display=$(load_recipients_display)
echo "Sending to: $recipients_display"
fi
# Send email
send_email_fastmail "$synopsis_file" "$recipients_json"
# Update tag for next run
update_tag
fi
echo ""
echo "Done!"
echo "PR details: $pr_file"
echo "Synopsis: $synopsis_file"
}
main
-149
View File
@@ -1,149 +0,0 @@
#!/bin/bash
#
# Setup script for Eagle0 production droplet
# Run this on a fresh DigitalOcean droplet (Ubuntu 24.04)
#
# Usage: curl -sSL https://raw.githubusercontent.com/nolen777/eagle0/main/scripts/setup_droplet.sh | sudo bash
#
set -euo pipefail
DOMAIN="${DOMAIN:-eagle0.net}"
DEPLOY_USER="${DEPLOY_USER:-deploy}"
APP_DIR="/opt/eagle0"
echo "=== Eagle0 Production Server Setup ==="
echo "Domain: ${DOMAIN}"
echo "Deploy user: ${DEPLOY_USER}"
echo ""
# Check if running as root
if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root (use sudo)"
exit 1
fi
echo "=== Updating system ==="
apt-get update
apt-get upgrade -y
echo "=== Installing Docker ==="
if ! command -v docker &> /dev/null; then
curl -fsSL https://get.docker.com | sh
systemctl enable docker
systemctl start docker
else
echo "Docker already installed"
fi
echo "=== Installing Docker Compose plugin ==="
apt-get install -y docker-compose-plugin
echo "=== Installing additional utilities ==="
apt-get install -y \
curl \
wget \
git \
netcat-openbsd \
jq \
htop \
unattended-upgrades
echo "=== Configuring automatic security updates ==="
cat > /etc/apt/apt.conf.d/20auto-upgrades << 'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
EOF
echo "=== Creating deploy user ==="
if ! id "${DEPLOY_USER}" &>/dev/null; then
useradd -m -s /bin/bash -G docker "${DEPLOY_USER}"
mkdir -p "/home/${DEPLOY_USER}/.ssh"
chmod 700 "/home/${DEPLOY_USER}/.ssh"
chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "/home/${DEPLOY_USER}/.ssh"
echo ""
echo "*** IMPORTANT: Add your SSH public key to /home/${DEPLOY_USER}/.ssh/authorized_keys ***"
echo ""
else
echo "User ${DEPLOY_USER} already exists"
# Ensure user is in docker group
usermod -aG docker "${DEPLOY_USER}"
fi
echo "=== Creating application directory ==="
mkdir -p "${APP_DIR}"/{nginx,certbot/conf,certbot/www,saves}
chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "${APP_DIR}"
echo "=== Configuring Docker registry authentication ==="
echo ""
echo "*** IMPORTANT: Run the following command to authenticate with DigitalOcean Container Registry: ***"
echo " docker login registry.digitalocean.com"
echo ""
echo "=== Creating systemd service ==="
cat > /etc/systemd/system/eagle0.service << EOF
[Unit]
Description=Eagle0 Game Servers
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=${APP_DIR}
ExecStart=/usr/bin/docker compose -f docker-compose.prod.yml up -d
ExecStop=/usr/bin/docker compose -f docker-compose.prod.yml down
User=${DEPLOY_USER}
Group=${DEPLOY_USER}
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable eagle0
echo "=== Configuring firewall (UFW) ==="
if ! command -v ufw &> /dev/null; then
apt-get install -y ufw
fi
ufw default deny incoming
ufw default allow outgoing
ufw allow ssh
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
echo "=== Setting up log rotation ==="
cat > /etc/logrotate.d/eagle0 << EOF
/var/log/eagle0/*.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
create 0640 ${DEPLOY_USER} ${DEPLOY_USER}
sharedscripts
}
EOF
mkdir -p /var/log/eagle0
chown "${DEPLOY_USER}:${DEPLOY_USER}" /var/log/eagle0
echo ""
echo "=== Setup Complete ==="
echo ""
echo "Next steps:"
echo "1. Add SSH public key to /home/${DEPLOY_USER}/.ssh/authorized_keys"
echo "2. Copy docker-compose.prod.yml to ${APP_DIR}/"
echo "3. Copy nginx/nginx.conf to ${APP_DIR}/nginx/"
echo "4. Create .env file in ${APP_DIR}/ with OPENAI_API_KEY"
echo "5. Run: docker login registry.digitalocean.com"
echo "6. Get SSL certificate: (see init_ssl.sh)"
echo "7. Start services: systemctl start eagle0"
echo ""
echo "Server IP: $(curl -s ifconfig.me)"
echo ""
@@ -26,13 +26,6 @@ namespace fs = std::filesystem;
static string rLocation;
auto rloc(const string& execPath) -> string {
// First check for environment variable override for Docker deployment
const char* resourcesPath = getenv("SHARDOK_RESOURCES_PATH");
if (resourcesPath != nullptr) {
return ""; // Return empty so StaticShardokFilesDirectory uses env var directly
}
// Fall back to Bazel runfiles for development
string error;
const std::unique_ptr<Runfiles> runfiles(Runfiles::Create(execPath, &error));
@@ -65,14 +58,10 @@ auto FilesystemUtils::FileExistsAtPath(const string& path) -> bool { return fs::
auto FilesystemUtils::StaticEagle0FilesDirectory() -> string { return "/usr/local/share/eagle0/"; }
auto FilesystemUtils::StaticShardokFilesDirectory() -> string {
const char* resourcesPath = getenv("SHARDOK_RESOURCES_PATH");
if (resourcesPath != nullptr) { return string(resourcesPath) + "/"; }
return rLocation + "/src/main/resources/net/eagle0/shardok/";
}
auto FilesystemUtils::MapFilesDirectory() -> string {
const char* mapsPath = getenv("SHARDOK_MAPS_PATH");
if (mapsPath != nullptr) { return string(mapsPath) + "/"; }
return StaticShardokFilesDirectory() + "maps/";
}
@@ -7,7 +7,6 @@
#include <algorithm>
#include <bit>
#include <cstdint>
#include <cstdlib>
#define ITERABLE_BITSET_INDEX_CHECKS false
@@ -9,7 +9,6 @@
#include "MapUtils.hpp"
#include <algorithm>
#include <stdexcept>
static inline std::string StringForKey(
const std::unordered_map<std::string, std::string>& map,
@@ -9,7 +9,6 @@
#ifndef byte_vector_h
#define byte_vector_h
#include <cstdint>
#include <cstring>
#include <fstream>
#include <sstream>
@@ -65,6 +65,11 @@ private:
const GameStateW& guessedState,
const CommandListSPtr& realAvailableCommands) const -> CommandChoiceResults;
[[nodiscard]] auto ChooseCommandIndex(
const GameSettingsSPtr& settings,
const net::eagle0::shardok::api::GameStateView& gsv,
const CommandListSPtr& realAvailableCommands) const -> CommandChoiceResults;
public:
explicit ShardokAIClient(
PlayerId playerId,
@@ -81,12 +86,6 @@ public:
[[nodiscard]] auto ChooseCommandIndex(const ShardokEngine& engine) const
-> CommandChoiceResults;
// Overload that works on copies of state - allows caller to release lock during AI thinking
[[nodiscard]] auto ChooseCommandIndex(
const GameSettingsSPtr& settings,
const net::eagle0::shardok::api::GameStateView& gsv,
const CommandListSPtr& realAvailableCommands) const -> CommandChoiceResults;
// MCTS configuration methods (only relevant when using MCTS algorithm)
[[nodiscard]] auto GetMCTSConfig() const -> const mcts::MCTSConfig& { return mctsConfig; }
void SetMCTSConfig(const mcts::MCTSConfig& config) { mctsConfig = config; }
@@ -4,7 +4,6 @@
#include "TranspositionTable.hpp"
#include <cinttypes>
#include <cstdio>
#include <cstring>
@@ -102,10 +101,10 @@ void TranspositionTable::clear() {
void TranspositionTable::printStats() const {
printf("TranspositionTable Stats:\n");
printf(" Probes: %" PRIu64 "\n", stats.probes.load());
printf(" Hits: %" PRIu64 " (%.1f%%)\n", stats.hits.load(), stats.hitRate());
printf(" Stores: %" PRIu64 "\n", stats.stores.load());
printf(" Collisions: %" PRIu64 "\n", stats.collisions.load());
printf(" Probes: %llu\n", stats.probes.load());
printf(" Hits: %llu (%.1f%%)\n", stats.hits.load(), stats.hitRate());
printf(" Stores: %llu\n", stats.stores.load());
printf(" Collisions: %llu\n", stats.collisions.load());
printf(" Table size: %zu entries (%.1f MB)\n",
TABLE_SIZE,
(TABLE_SIZE * sizeof(TTEntry)) / (1024.0 * 1024.0));
@@ -10,6 +10,7 @@
#include "AiBattleConfig.hpp"
#include "AiBattleSimulator.hpp"
#include "src/main/cpp/net/eagle0/common/FilesystemUtils.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
using shardok::ai_battle_simulator::AiBattleConfigLoader;
using shardok::ai_battle_simulator::AiBattleSimulator;
@@ -108,6 +109,10 @@ int main(int argc, char* argv[]) {
// Set exec path for FilesystemUtils
FilesystemUtils::SetExecPath(argv[0]);
// Set cache directory for ActionPointDistances
shardok::FixedActionPointDistances::SetCacheDirectory(
FilesystemUtils::CacheFilesDirectory() + "apdCache/");
try {
if (argc < 2) {
PrintUsage(argv[0]);
@@ -16,6 +16,7 @@
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/AIClientFactory.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai_testing_common/GamePhaseRunner.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokEngine.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
#include "src/main/protobuf/net/eagle0/shardok/common/command_type.pb.h"
using namespace shardok;
@@ -83,6 +84,10 @@ int main(int argc, char* argv[]) {
// Set exec path so FilesystemUtils can find resource files
FilesystemUtils::SetExecPath(argv[0]);
// Set cache directory for ActionPointDistances
FixedActionPointDistances::SetCacheDirectory(
FilesystemUtils::CacheFilesDirectory() + "apdCache/");
try {
std::cout << "Shardok AI Performance Runner\n";
std::cout << "==============================\n";
@@ -111,67 +111,17 @@ void ShardokGameController::DoAIThread() {
if (aiClients.empty()) { printf("No AI players, exiting AI thread.\n"); }
while (aiThreadKeepGoing) {
// Phase 1: Gather data for AI decision (brief lock)
shared_ptr<ShardokAIClient> aiClient;
PlayerId playerId;
GameSettingsSPtr settings;
net::eagle0::shardok::api::GameStateView gsv;
CommandListSPtr availableCommands;
size_t expectedHistoryCount;
{
unique_lock lk(masterLock);
if (engine->GameIsOver()) {
aiThreadKeepGoing = false;
continue;
}
playerId = engine->GetCurrentPlayerId();
aiClient = LockedAIClientForPid(playerId);
if (!aiClient) {
// Not an AI player's turn - wait for signal
aiCondition.wait(lk);
aiThreadKeepGoing = aiThreadKeepGoing && !engine->GameIsOver();
continue;
}
// Get copies of everything the AI needs
settings = engine->GetGameSettings();
gsv = engine->GetGameStateView(playerId);
availableCommands = engine->GetAvailableCommandsForAIPlayer(playerId);
expectedHistoryCount = engine->GetUnfilteredHistoryCount();
}
// Lock released - polls can now get through
if (availableCommands->empty()) {
printf("no commands for player %d\n", playerId);
continue;
while (incomingRegistrations > 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
// Phase 2: AI thinks (NO LOCK - this is the slow part)
const auto results = aiClient->ChooseCommandIndex(settings, gsv, availableCommands);
// Phase 3: Post the command (brief lock)
{
unique_lock lk(masterLock);
// Verify state hasn't changed while we were thinking
if (engine->GetUnfilteredHistoryCount() != expectedHistoryCount) {
// State changed (e.g., human posted command) - re-evaluate
printf("AI: State changed while thinking, re-evaluating\n");
continue;
}
if (engine->GameIsOver()) {
aiThreadKeepGoing = false;
continue;
}
engine->PostCommand(playerId, results.chosenIndex);
unique_lock lk(masterLock);
if (LockedCheckOneAICommand()) {
LockedNotifyClients();
aiThreadKeepGoing = !engine->GameIsOver();
aiThreadKeepGoing = aiThreadKeepGoing && !engine->GameIsOver();
} else {
aiCondition.wait(lk);
aiThreadKeepGoing = aiThreadKeepGoing && !engine->GameIsOver();
}
}
printf("Exiting AI thread.\n");
@@ -186,6 +136,24 @@ ShardokGameController::~ShardokGameController() {
aiThread.join();
}
auto ShardokGameController::LockedCheckOneAICommand() -> bool {
if (engine->GameIsOver()) {
printf("Game is over!\n");
return false;
}
const PlayerId currentPid = engine->GetCurrentPlayerId();
if (const shared_ptr<ShardokAIClient> currentPlayerClient = LockedAIClientForPid(currentPid)) {
const int index = currentPlayerClient->ChooseCommandIndex(*engine).chosenIndex;
engine->PostCommand(currentPid, index);
LockedNotifyClients();
return true;
}
return false;
}
void CheckFactionId(
const unique_ptr<ShardokEngine> &engine,
const PlayerId shardokPlayerId,
@@ -261,7 +229,6 @@ void ShardokGameController::PostPlacementCommands(
}
auto ShardokGameController::GetCurrentGameStateBytes() -> byte_vector {
scoped_lock<mutex> guard(masterLock);
return engine->GetCurrentGameStateBytes();
}
@@ -325,9 +292,6 @@ auto ShardokGameController::GetUpdates(const int64_t startingActionId) -> AllUpd
-1,
engine->FilterNewResults(-1, startingActionId),
nullptr);
auto gameStateBytes = engine->GetCurrentGameStateBytes();
updates.currentGameState.swap(gameStateBytes);
}
return updates;
@@ -357,74 +321,4 @@ auto ShardokGameController::ResolvedPlayerInfos()
return engine->GetPlayerInfos();
}
void ShardokGameController::RegisterSubscriber(std::shared_ptr<StreamSubscriber> subscriber) {
scoped_lock<mutex> guard(subscriberLock);
subscribers.push_back(subscriber);
}
void ShardokGameController::UnregisterSubscriber(const StreamSubscriber *subscriber) {
scoped_lock<mutex> guard(subscriberLock);
subscribers.erase(
std::remove_if(
subscribers.begin(),
subscribers.end(),
[subscriber](const std::weak_ptr<StreamSubscriber> &weakSub) {
auto sub = weakSub.lock();
return !sub || sub.get() == subscriber;
}),
subscribers.end());
}
auto ShardokGameController::WaitForUpdatesAndPush(
std::shared_ptr<StreamSubscriber> subscriber,
int64_t startingActionId) -> bool {
int64_t lastPushedActionId = startingActionId;
while (subscriber->IsActive()) {
bool gameOver = false;
GameOverInfo gameOverInfo{};
{
unique_lock<mutex> guard(masterLock);
// Wait for updates or game over
updateCondition.wait(guard, [this, lastPushedActionId] {
return engine->GetUnfilteredHistoryCount() >
static_cast<size_t>(lastPushedActionId) ||
engine->GameIsOver();
});
if (!subscriber->IsActive()) { return false; }
gameOver = engine->GameIsOver();
if (gameOver) {
gameOverInfo.gameStatus = fb::ToProto(engine->GetGameStatus());
gameOverInfo.playerInfos = engine->GetPlayerInfos();
gameOverInfo.endGameUnits = engine->EndGameUnits();
}
}
// Lock released - GetUpdates will acquire its own lock
if (gameOver) {
subscriber->OnGameOver(gameOverInfo);
return true;
}
// Get updates outside the lock (GetUpdates acquires masterLock internally)
AllUpdates updates = GetUpdates(lastPushedActionId);
lastPushedActionId = updates.newUnfilteredCount;
if (!updates.mainResults.empty()) {
subscriber->OnUpdate(
updates.mainResults,
updates.filteredResults,
updates.newUnfilteredCount,
updates.currentGameState);
}
}
return false; // Subscriber disconnected
}
} // namespace shardok
@@ -10,7 +10,6 @@
#define ShardokGameController_hpp
#include <functional>
#include <memory>
#include <mutex>
#include <utility>
#include <vector>
@@ -30,50 +29,6 @@ using std::shared_ptr;
using std::unique_ptr;
using std::weak_ptr;
// Forward declaration
class ShardokGameController;
/// Info about a game that has ended, for notifying subscribers
struct GameOverInfo {
vector<net::eagle0::shardok::common::PlayerInfo> playerInfos;
vector<net::eagle0::shardok::storage::ResolvedUnit> endGameUnits;
net::eagle0::shardok::common::GameStatus gameStatus;
};
/// Updates for a single player (includes faction ID for client routing)
struct OnePlayerUpdates {
int32_t eagleFactionId;
vector<ActionResultView> resultViews;
shared_ptr<AvailableCommands> availableCommands;
OnePlayerUpdates(
const int32_t fid,
const vector<ActionResultView>& arvs,
const shared_ptr<AvailableCommands>& acs)
: eagleFactionId(fid),
resultViews(arvs),
availableCommands(acs) {}
};
/// Interface for subscribers that receive streaming updates from a game
class StreamSubscriber {
public:
virtual ~StreamSubscriber() = default;
/// Called when new game updates are available
virtual void OnUpdate(
const vector<ActionResult>& mainResults,
const vector<OnePlayerUpdates>& filteredResults,
int32_t newUnfilteredCount,
const byte_vector& currentGameState) = 0;
/// Called when the game ends
virtual void OnGameOver(const GameOverInfo& info) = 0;
/// Returns true if this subscriber is still active and should receive updates
[[nodiscard]] virtual auto IsActive() const -> bool = 0;
};
class ShardokGameController {
private:
// This lock should be held any time we call into engine or modify clients.
@@ -84,17 +39,10 @@ private:
// Fires whenever there is a new game state update.
mutable std::condition_variable updateCondition{};
// Stream subscribers - protected by separate lock to avoid deadlock with masterLock
mutable std::mutex subscriberLock{};
std::vector<std::weak_ptr<StreamSubscriber>> subscribers{};
string serializedRequest;
unique_ptr<ShardokEngine> engine;
// Cached immutable data - safe to access without lock since it never changes after construction
const GameId cachedGameId;
std::atomic_int incomingRegistrations = 0;
const string mapName;
@@ -112,9 +60,11 @@ private:
void LockedNotifyClients() const;
auto LockedCheckOneAICommand() -> bool;
auto LockedAIClientForPid(PlayerId pid) const -> shared_ptr<ShardokAIClient>;
static vector<shared_ptr<ShardokAIClient>> MakeAIClients(const unique_ptr<ShardokEngine>& e);
static vector<shared_ptr<ShardokAIClient>> MakeAIClients(const unique_ptr<ShardokEngine> &e);
void DoAIThread();
@@ -125,7 +75,6 @@ public:
string serializedRequest = "")
: serializedRequest(std::move(serializedRequest)),
engine(std::move(e)),
cachedGameId(engine->GetGameId()),
mapName(std::move(mapName)),
logFilePath(MakeLogFilePath()),
aiClients(MakeAIClients(engine)),
@@ -149,13 +98,26 @@ public:
PlayerId shardokPlayerId,
int eagleFactionId,
int64_t token,
const vector<UnitPlacementInfo>& infos);
const vector<UnitPlacementInfo> &infos);
struct OnePlayerUpdates {
int32_t eagleFactionId;
vector<ActionResultView> resultViews;
shared_ptr<AvailableCommands> availableCommands;
OnePlayerUpdates(
const int32_t fid,
const vector<ActionResultView> &arvs,
const shared_ptr<AvailableCommands> &acs)
: eagleFactionId(fid),
resultViews(arvs),
availableCommands(acs) {}
};
struct AllUpdates {
vector<ActionResult> mainResults;
vector<OnePlayerUpdates> filteredResults;
int32_t newUnfilteredCount;
byte_vector currentGameState;
};
auto GetUpdates(int64_t startingActionId) -> AllUpdates;
auto GetCurrentGameStateBytes() -> byte_vector;
@@ -165,23 +127,13 @@ public:
auto ResolvedPlayerInfos() -> vector<net::eagle0::shardok::common::PlayerInfo>;
auto EndGameUnits() -> vector<net::eagle0::shardok::storage::ResolvedUnit>;
[[nodiscard]] auto GetGameId() const -> GameId { return cachedGameId; }
[[nodiscard]] auto GetGameId() const -> GameId { return engine->GetGameId(); }
[[nodiscard]] auto GetHexMap() const -> const HexMap * {
return engine->GetCurrentGameState()->hex_map();
}
[[nodiscard]] auto GetLogFilePath() const -> string { return logFilePath; }
/// Register a subscriber to receive streaming updates for this game.
/// The subscriber will receive updates until it becomes inactive or is unregistered.
void RegisterSubscriber(std::shared_ptr<StreamSubscriber> subscriber);
/// Unregister a subscriber. Safe to call even if the subscriber was never registered.
void UnregisterSubscriber(const StreamSubscriber* subscriber);
/// Wait for game updates, pushing them to the given subscriber.
/// Blocks until the game ends or the subscriber becomes inactive.
/// Returns true if the game ended normally, false if subscriber disconnected.
auto WaitForUpdatesAndPush(
std::shared_ptr<StreamSubscriber> subscriber,
int64_t startingActionId) -> bool;
};
} // namespace shardok
@@ -29,6 +29,8 @@ thread_local struct {
int localMisses = 0;
int sharedAccesses = 0;
int evictionEvents = 0;
int apdLoadedFromFile = 0;
int apdGeneratedFresh = 0;
std::chrono::steady_clock::time_point lastReportTime = std::chrono::steady_clock::now();
} cacheStats;
@@ -38,13 +40,16 @@ static void MaybePrintCacheStats() {
if (std::chrono::duration_cast<std::chrono::seconds>(now - cacheStats.lastReportTime).count() >=
CACHE_STATS_FREQUENCY_SECONDS_) {
printf("Thread cache stats: %d persistent hits, %d persistent misses, %d local hits, "
"%d local misses, %d shared accesses, %d eviction events\n",
"%d local misses, %d shared accesses, %d eviction events, "
"%d APD loaded from file, %d APD generated fresh\n",
cacheStats.persistentHits,
cacheStats.persistentMisses,
cacheStats.localHits,
cacheStats.localMisses,
cacheStats.sharedAccesses,
cacheStats.evictionEvents);
cacheStats.evictionEvents,
cacheStats.apdLoadedFromFile,
cacheStats.apdGeneratedFresh);
cacheStats.lastReportTime = now;
}
}
@@ -190,12 +195,25 @@ auto ActionPointDistancesCache::GetRaw(
}
// Create new pathfinding result using factory method
auto result = FixedActionPointDistances::Create(
auto creationResult = FixedActionPointDistances::Create(
mapToUse,
mapId.terrainTypesId,
mapId.modifierId,
battalionType,
includeBravingWater,
braveWaterActionPointCost);
#if CACHE_STATS_LOGGING_
// Track whether this was loaded from file or generated fresh
if (creationResult.loadedFromFile) {
cacheStats.apdLoadedFromFile++;
} else {
cacheStats.apdGeneratedFresh++;
}
#endif
auto result = creationResult.apd;
// Store in shared cache
sharedDistances.lazy_emplace_l(
cacheKey,
@@ -22,56 +22,110 @@ static const int ASYNC_COUNT = []() {
namespace shardok {
void FixedActionPointDistances::SetCacheDirectory(const string& newDir) {
cacheDirectory = newDir;
FilesystemUtils::MakeDirectoryIfNecessary(cacheDirectory);
}
static thread_local byte_vector _scratch;
FixedActionPointDistances::FixedActionPointDistances(const HexMap* /*map*/, int columnCount)
: ActionPointDistances(columnCount) {}
auto FixedActionPointDistances::Create(
const HexMap* map,
int64_t terrainTypesHash,
int64_t modifierHash,
const BattalionTypeSPtr& battalionType,
bool includeBravingWater,
int braveWaterActionPointCost) -> std::shared_ptr<FixedActionPointDistances> {
int braveWaterActionPointCost) -> CreationResult {
// Create the object using private constructor
auto apd = std::shared_ptr<FixedActionPointDistances>(
new FixedActionPointDistances(map, map->column_count()));
CreationResult result;
result.apd = apd;
result.loadedFromFile = false;
string path = "";
if (!cacheDirectory.empty()) {
std::stringstream stream;
stream << cacheDirectory;
stream << std::hex << terrainTypesHash << '/';
if (auto directoryPath = stream.str(); !FilesystemUtils::FileExistsAtPath(directoryPath)) {
FilesystemUtils::MakeDirectoryIfNecessary(stream.str());
}
stream << std::hex << modifierHash;
stream << " " << battalionType->typeId;
if (includeBravingWater) { stream << " " << braveWaterActionPointCost; }
stream << ".apd";
path = stream.str();
}
const int indexCount = map->row_count() * map->column_count();
vector<std::future<vector<vector<DIST_T>>>> futures(indexCount);
if (!path.empty() && FilesystemUtils::FileExistsAtPath(path)) {
apd->distances.resize(indexCount);
// load from file
const auto& bytes = _scratch.ReplaceWithPath(path);
const auto* ptr = reinterpret_cast<const DIST_T*>(bytes.data());
auto braveWaterPossibleCoords =
includeBravingWater ? apd->BraveWaterPossibleCoords(map) : nullptr;
for (int fromIndex = 0; fromIndex < indexCount; fromIndex++) {
apd->distances[fromIndex].insert(
apd->distances[fromIndex].end(),
&(ptr[0]),
&(ptr[indexCount]));
ptr += indexCount;
}
result.loadedFromFile = true;
} else {
_scratch.reserve(indexCount * indexCount * sizeof(DIST_T));
int chunkSize = (indexCount + ASYNC_COUNT - 1) / ASYNC_COUNT;
// Break into chunks for async
for (int chunkIdx = 0; chunkIdx < ASYNC_COUNT; chunkIdx++) {
futures[chunkIdx] = std::async(std::launch::async, [=]() -> vector<vector<DIST_T>> {
vector<vector<DIST_T>> chunkVec;
chunkVec.reserve(chunkSize);
const int chunkStartIndex = chunkIdx * chunkSize;
vector<std::future<vector<vector<DIST_T>>>> futures(indexCount);
for (int i = 0; i < chunkSize; i++) {
const auto fromIndex = chunkStartIndex + i;
if (fromIndex >= indexCount) { continue; }
chunkVec.push_back(ActionPointDistances::GenerateDistances(
fromIndex,
map,
includeBravingWater,
braveWaterActionPointCost,
battalionType,
braveWaterPossibleCoords));
}
return chunkVec;
});
auto braveWaterPossibleCoords =
includeBravingWater ? apd->BraveWaterPossibleCoords(map) : nullptr;
int chunkSize = (indexCount + ASYNC_COUNT - 1) / ASYNC_COUNT;
// Break into chunks for async
for (int chunkIdx = 0; chunkIdx < ASYNC_COUNT; chunkIdx++) {
futures[chunkIdx] = std::async(std::launch::async, [=]() -> vector<vector<DIST_T>> {
vector<vector<DIST_T>> chunkVec;
chunkVec.reserve(chunkSize);
const int chunkStartIndex = chunkIdx * chunkSize;
for (int i = 0; i < chunkSize; i++) {
const auto fromIndex = chunkStartIndex + i;
if (fromIndex >= indexCount) { continue; }
chunkVec.push_back(ActionPointDistances::GenerateDistances(
fromIndex,
map,
includeBravingWater,
braveWaterActionPointCost,
battalionType,
braveWaterPossibleCoords));
}
return chunkVec;
});
}
apd->distances.reserve(indexCount);
_scratch.clear();
_scratch.reserve(indexCount * indexCount * sizeof(DIST_T));
for (int chunkIdx = 0; chunkIdx < ASYNC_COUNT; chunkIdx++) {
auto resultsVec = futures[chunkIdx].get();
apd->distances.insert(apd->distances.end(), resultsVec.begin(), resultsVec.end());
for (const auto& r : resultsVec) { _scratch.append(r); }
}
if (!path.empty()) { FilesystemUtils::AtomicallySaveToPath(path, _scratch); }
}
apd->distances.reserve(indexCount);
for (int chunkIdx = 0; chunkIdx < ASYNC_COUNT; chunkIdx++) {
auto resultsVec = futures[chunkIdx].get();
apd->distances.insert(apd->distances.end(), resultsVec.begin(), resultsVec.end());
}
return apd;
return result;
}
} // namespace shardok
@@ -17,19 +17,31 @@ using std::vector;
using BattalionTypeSPtr = std::shared_ptr<const BattalionType>;
class FixedActionPointDistances final : public ActionPointDistances {
public:
struct CreationResult {
std::shared_ptr<FixedActionPointDistances> apd;
bool loadedFromFile;
};
private:
vector<vector<DIST_T>> distances;
inline static string cacheDirectory = "";
// Private constructor - use Create factory method instead
explicit FixedActionPointDistances(const HexMap *map, int columnCount);
public:
// Factory method to create FixedActionPointDistances
static void SetCacheDirectory(const string &newDir);
// Factory method to create FixedActionPointDistances with metadata
static auto Create(
const HexMap *map,
int64_t terrainTypesHash,
int64_t modifierHash,
const BattalionTypeSPtr &battalionType,
bool includeBravingWater,
int braveWaterActionPointCost = -1) -> std::shared_ptr<FixedActionPointDistances>;
int braveWaterActionPointCost = -1) -> CreationResult;
~FixedActionPointDistances() override = default;
@@ -40,6 +52,8 @@ public:
[[nodiscard]] auto Distance(const Coords &from, const Coords &to) const -> DIST_T override {
return Distance(ToIndex(from), ToIndex(to));
}
friend struct CreationResult;
};
} // namespace shardok
@@ -529,130 +529,6 @@ auto FromInternalStatus(
throw ShardokInternalErrorException("Bad unit status on resolved unit");
}
auto EagleInterfaceImpl::SubscribeToGame(
ServerContext *context,
const GameSubscriptionRequest *request,
grpc::ServerWriter<GameStatusResponse> *writer) -> Status {
shared_ptr<ShardokGameController> controller;
try {
controller = ControllerForGame(request->game_id(), request->game_setup_info());
} catch (NewGameException &e) { return e.GetStatus(); }
if (!controller) { return Status(StatusCode::NOT_FOUND, "Game not found"); }
// Send initial state
GameStatusResponse initialResponse;
PopulateGameStatusResponse(
controller,
request->game_setup_info().known_result_count(),
&initialResponse);
if (!writer->Write(initialResponse)) {
return Status::OK; // Client disconnected
}
// If game was already over, we're done
if (initialResponse.has_game_over_response()) { return Status::OK; }
// Track the actual count after initial response to avoid duplicate sends
const int64_t countAfterInitialResponse =
initialResponse.has_game_update_response()
? initialResponse.game_update_response().total_action_result_count()
: request->game_setup_info().known_result_count();
// Create a subscriber that writes to the gRPC stream
class GrpcStreamSubscriber : public StreamSubscriber {
private:
grpc::ServerWriter<GameStatusResponse> *writer_;
ServerContext *context_;
std::atomic<bool> active_{true};
std::string gameId_;
public:
GrpcStreamSubscriber(
grpc::ServerWriter<GameStatusResponse> *w,
ServerContext *ctx,
std::string gameId)
: writer_(w),
context_(ctx),
gameId_(std::move(gameId)) {}
void OnUpdate(
const vector<ActionResult> &mainResults,
const vector<OnePlayerUpdates> &filteredResults,
int32_t newUnfilteredCount,
const byte_vector &currentGameState) override {
if (!active_) return;
GameStatusResponse response;
response.set_game_id(gameId_);
response.mutable_game_update_response()->mutable_update_responses()->Add(
begin(mainResults),
end(mainResults));
// Add filtered results for each player with their faction IDs
for (const auto &playerUpdate : filteredResults) {
auto *filtered =
response.mutable_game_update_response()->add_filtered_update_responses();
filtered->set_eagle_faction_id(playerUpdate.eagleFactionId);
filtered->mutable_action_result_views()->Add(
begin(playerUpdate.resultViews),
end(playerUpdate.resultViews));
if (playerUpdate.availableCommands) {
*filtered->mutable_available_commands() = *playerUpdate.availableCommands;
}
}
response.mutable_game_update_response()->set_total_action_result_count(
newUnfilteredCount);
*response.mutable_game_update_response()->mutable_current_game_state() =
std::string(currentGameState.begin(), currentGameState.end());
if (!writer_->Write(response)) { active_ = false; }
}
void OnGameOver(const GameOverInfo &info) override {
if (!active_) return;
GameStatusResponse response;
response.set_game_id(gameId_);
PopulateGameOverResponse(
gameId_,
info.gameStatus,
info.playerInfos,
info.endGameUnits,
response.mutable_game_over_response());
writer_->Write(response);
active_ = false;
}
[[nodiscard]] auto IsActive() const -> bool override {
return active_ && !context_->IsCancelled();
}
};
auto subscriber =
std::make_shared<GrpcStreamSubscriber>(writer, context, controller->GetGameId());
controller->RegisterSubscriber(subscriber);
// Wait for updates and push them until game ends or subscriber disconnects
// Use countAfterInitialResponse to avoid re-sending results already in initial response
bool gameEnded = controller->WaitForUpdatesAndPush(subscriber, countAfterInitialResponse);
controller->UnregisterSubscriber(subscriber.get());
if (gameEnded) {
printf("SubscribeToGame: Game ended normally\n");
} else {
printf("SubscribeToGame: Subscriber disconnected\n");
}
return Status::OK;
}
} // namespace shardok
#ifndef NDEBUG
@@ -33,7 +33,6 @@ using grpc::Status;
using net::eagle0::common::GameSetupInfo;
using net::eagle0::common::GameStatusRequest;
using net::eagle0::common::GameStatusResponse;
using net::eagle0::common::GameSubscriptionRequest;
using net::eagle0::common::HexMapNamesRequest;
using net::eagle0::common::HexMapNamesResponse;
using net::eagle0::common::HexMapRequest;
@@ -79,11 +78,6 @@ public:
ServerContext* context,
const HexMapNamesRequest* request,
HexMapNamesResponse* response) -> Status override;
auto SubscribeToGame(
ServerContext* context,
const GameSubscriptionRequest* request,
grpc::ServerWriter<GameStatusResponse>* writer) -> Status override;
};
} // namespace shardok
@@ -8,7 +8,6 @@
#include "ServerConfiguration.hpp"
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <iostream>
@@ -31,16 +30,9 @@ using std::unordered_map;
unordered_map<string, string> DefaultValues();
unordered_map<string, string> DefaultValues() {
// Check environment variables for Docker deployment
// Docker containers should set SHARDOK_EAGLE_INTERFACE_ADDRESS=0.0.0.0:40042
// to listen on all interfaces for container networking
const char* eagleInterfaceAddr = getenv("SHARDOK_EAGLE_INTERFACE_ADDRESS");
const char* shardokAddr = getenv("SHARDOK_GRPC_ADDRESS");
return unordered_map<string, string>{
{ServerConfiguration::kShardokGrpcAddress, shardokAddr ? shardokAddr : "localhost"},
{ServerConfiguration::kEagleInterfaceGrpcAddress,
eagleInterfaceAddr ? eagleInterfaceAddr : "localhost:40042"}};
{ServerConfiguration::kShardokGrpcAddress, "localhost"},
{ServerConfiguration::kEagleInterfaceGrpcAddress, "localhost"}};
}
ServerConfiguration::ServerConfiguration(const string& filePath) {
@@ -15,6 +15,7 @@
#include "ServerConfiguration.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
#include "src/main/cpp/net/eagle0/shardok/controller/ShardokGameController.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/action_point_distances/FixedActionPointDistances.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings_loader/SettingsLoader.hpp"
#include "src/main/cpp/net/eagle0/shardok/util/MapLoader.hpp"
#include "src/main/flatbuffer/net/eagle0/shardok/storage/unit.hpp"
@@ -44,6 +45,9 @@ ShardokGamesManager::ShardokGamesManager(const std::vector<std::string> &extraSe
std::cerr << "NOT SETTING" << std::endl;
// setter.SetRaw(key, value);
}
FixedActionPointDistances::SetCacheDirectory(
FilesystemUtils::CacheFilesDirectory() + "apdCache/");
}
auto ShardokGamesManager::GetController(const GameId &gameId)
@@ -77,14 +77,11 @@ public class AuthInterceptor : Interceptor {
};
public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public TMP_Dropdown environmentDropdown;
public TMP_InputField urlField;
public TMP_InputField nameField;
public TMP_InputField passwordField;
public TMP_Dropdown resolutionDropdown;
[Header("Status Display")]
public TextMeshProUGUI connectionStatusText;
public GameObject connectionPanel;
public GameObject gameSelectionPanel;
public GameObject customBattlePanel;
@@ -115,22 +112,11 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public ClientPregeneratedText clientPregeneratedText;
const String BaseDomain = "eagle0.net";
const String EnvironmentKey = "environmentKey";
const String DefaultUrl = "eagle0.net";
const String URLKey = "urlKey";
const String NameKey = "nameKey";
const String PasswordKey = "passwordKey";
// Environment options: index 0 = prod., index 1 = qa., index 2 = (none)
private static readonly List<string> EnvironmentOptions = new() { "prod.", "qa.", "" };
private static readonly List<string> EnvironmentDisplayNames =
new() { "prod.", "qa.", "(none)" };
private string GetUrlFromEnvironment() {
int envIndex = environmentDropdown.value;
string prefix = EnvironmentOptions[envIndex];
return prefix + BaseDomain;
}
public void ReceiveLobbyUpdate(LobbyResponse lobbyResponse) {
_handleLobbyResponse(lobbyResponse);
}
@@ -147,11 +133,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
resolutionDropdown.AddOptions(resolutions.Select(r => $"{r.width} x {r.height}").ToList());
resolutionDropdown.value = resolutions.ToList().IndexOf(currentResolution);
// Set up environment dropdown
environmentDropdown.ClearOptions();
environmentDropdown.AddOptions(EnvironmentDisplayNames);
environmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
urlField.text = PlayerPrefs.GetString(URLKey, DefaultUrl);
nameField.text = PlayerPrefs.GetString(NameKey, "sample_name");
passwordField.text = PlayerPrefs.GetString(PasswordKey, "sample_password");
@@ -165,78 +147,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
customBattlePanel.gameObject.SetActive(false);
errorHandler.gameObject.SetActive(true);
// Initialize status text
UpdateConnectionStatus();
}
private float _statusUpdateTimer = 0f;
private const float StatusUpdateInterval = 0.5f;
void Update() {
// Only update status periodically and when connection panel is visible
if (!connectionPanel.activeInHierarchy) return;
_statusUpdateTimer += Time.deltaTime;
if (_statusUpdateTimer >= StatusUpdateInterval) {
_statusUpdateTimer = 0f;
UpdateConnectionStatus();
}
}
private void UpdateConnectionStatus() {
if (connectionStatusText == null) return;
// No connection yet - show nothing
if (_persistentClientConnection == null) {
connectionStatusText.text = "";
return;
}
var circuitState = _persistentClientConnection.CircuitBreaker.CurrentState;
var connState = _persistentClientConnection.CurrentState;
// Circuit breaker takes precedence
if (circuitState == eagle.ConnectionCircuitBreaker.State.Open) {
var nextTest = _persistentClientConnection.CircuitBreaker.NextTestAttempt;
if (nextTest.HasValue) {
var timeUntilTest = nextTest.Value - DateTime.UtcNow;
if (timeUntilTest.TotalSeconds > 0) {
int seconds = Math.Min(10, (int)Math.Ceiling(timeUntilTest.TotalSeconds));
connectionStatusText.text =
$"<color=red>●</color> Server unavailable. Retry in {seconds}s";
return;
}
}
connectionStatusText.text = "<color=red>●</color> Server unavailable";
return;
}
if (circuitState == eagle.ConnectionCircuitBreaker.State.HalfOpen) {
connectionStatusText.text = "<color=yellow>●</color> Testing connection...";
return;
}
// Normal connection states
connectionStatusText.text = connState switch {
eagle.ConnectionState.Connected => "<color=green>●</color> Connected",
eagle.ConnectionState.Connecting => "<color=yellow>●</color> Connecting...",
eagle.ConnectionState.Disconnected => "<color=red>●</color> Disconnected",
eagle.ConnectionState.Reconnecting => GetReconnectingText(),
eagle.ConnectionState.SubscriptionPending => "<color=yellow>●</color> Subscribing...",
_ => ""
};
}
private string GetReconnectingText() {
var nextAttempt = _persistentClientConnection?.NextReconnectAttempt;
if (!nextAttempt.HasValue) return "<color=yellow>●</color> Reconnecting...";
var timeUntilRetry = nextAttempt.Value - DateTime.UtcNow;
if (timeUntilRetry.TotalSeconds <= 0) { return "<color=yellow>●</color> Reconnecting..."; }
int seconds = Math.Min(10, (int)Math.Ceiling(timeUntilRetry.TotalSeconds));
return $"<color=orange>●</color> Retry in {seconds}s";
}
public void EditorButtonClicked() { SceneManager.LoadScene("Map Editor"); }
@@ -357,7 +267,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
private void _createConnection() {
PlayerPrefs.SetInt(EnvironmentKey, environmentDropdown.value);
PlayerPrefs.SetString(URLKey, urlField.text);
PlayerPrefs.SetString(NameKey, nameField.text);
PlayerPrefs.SetString(PasswordKey, passwordField.text);
@@ -370,8 +280,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
// Initialize cancellation token for thread management
_cancellationTokenSource = new CancellationTokenSource();
string url = GetUrlFromEnvironment();
eagleConnection = new EagleConnection(nameField.text, passwordField.text, url);
eagleConnection = new EagleConnection(nameField.text, passwordField.text, urlField.text);
_persistentClientConnection = new PersistentClientConnection(
eagleConnection.EagleGrpcClient,
eagleConnection.credentials,
@@ -1,8 +1,6 @@
using System;
using System.Threading;
#if UNITY_EDITOR
using UnityEditor;
#endif
using UnityEngine;
public class ConnectionKiller : MonoBehaviour {
@@ -12,10 +10,6 @@ public class ConnectionKiller : MonoBehaviour {
public void OnApplicationQuit() { ShutdownAll(); }
#if UNITY_EDITOR
void OnEnable() { EditorApplication.playModeStateChanged += OnPlayModeStateChanged; }
void OnDisable() { EditorApplication.playModeStateChanged -= OnPlayModeStateChanged; }
void OnPlayModeStateChanged(PlayModeStateChange state) {
if (state == PlayModeStateChange.ExitingPlayMode) { ShutdownAll(); }
}
@@ -120,19 +120,6 @@ namespace eagle {
}
}
/// <summary>
/// Force the circuit breaker to allow an immediate reconnect attempt.
/// Resets the state to HalfOpen so the next connection will be a test.
/// </summary>
public void ForceReconnect() {
lock (this) {
if (_state == State.Open) {
_state = State.HalfOpen;
_logger.LogLine("[CIRCUIT] OPEN → HALF_OPEN (forced by user)");
}
}
}
/// <summary>
/// Get human-readable status for UI display.
/// </summary>
@@ -151,7 +138,7 @@ namespace eagle {
var timeUntilTest = OpenTimeoutSeconds -
(DateTime.UtcNow - _openedAt.Value).TotalSeconds;
if (timeUntilTest > 0) {
return $"Server unavailable. Retrying in {Math.Min(10, (int)timeUntilTest)}s";
return $"Server unavailable. Retrying in {(int)timeUntilTest}s";
}
}
return "Server unavailable. Testing...";
@@ -2,7 +2,6 @@ using System;
using Net.Eagle0.Eagle.Api;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace eagle {
/// <summary>
@@ -27,9 +26,6 @@ namespace eagle {
private PersistentClientConnection _connection;
private IGameStateProvider _gameStateProvider;
[Tooltip("Optional button to force immediate reconnection when server is down")]
public Button retryButton;
// Update interval in seconds
private const float UpdateInterval = 0.5f;
private float _timeSinceLastUpdate = 0f;
@@ -43,10 +39,9 @@ namespace eagle {
return;
}
if (retryButton != null) {
retryButton.onClick.AddListener(OnRetryClicked);
retryButton.gameObject.SetActive(false);
}
// Find the PersistentClientConnection - this assumes it's accessible
// In production, this would need proper dependency injection
// For now, the connection will be set externally or found via another method
}
/// <summary>
@@ -78,12 +73,11 @@ namespace eagle {
// Check circuit breaker state first - it takes precedence
var circuitState = _connection.CircuitBreaker.CurrentState;
if (circuitState == ConnectionCircuitBreaker.State.Open) {
SetRetryButtonVisible(true);
var nextTest = _connection.CircuitBreaker.NextTestAttempt;
if (nextTest.HasValue) {
var timeUntilTest = nextTest.Value - DateTime.UtcNow;
if (timeUntilTest.TotalSeconds > 0) {
int seconds = Math.Min(10, (int)Math.Ceiling(timeUntilTest.TotalSeconds));
int seconds = (int)Math.Ceiling(timeUntilTest.TotalSeconds);
_textComponent.text =
$"<color=red>●</color> Server down. Retry in {seconds}s";
return;
@@ -92,7 +86,6 @@ namespace eagle {
_textComponent.text = "<color=red>●</color> Server unavailable";
return;
} else if (circuitState == ConnectionCircuitBreaker.State.HalfOpen) {
SetRetryButtonVisible(false);
_textComponent.text = "<color=yellow>●</color> Testing connection...";
return;
}
@@ -101,11 +94,6 @@ namespace eagle {
var state = _connection.CurrentState;
var nextAttempt = _connection.NextReconnectAttempt;
// Show retry button if we're counting down to a reconnect attempt
bool isCountingDown = state == ConnectionState.Reconnecting && nextAttempt.HasValue &&
(nextAttempt.Value - DateTime.UtcNow).TotalSeconds > 0;
SetRetryButtonVisible(isCountingDown);
string statusText = state switch {
ConnectionState.Connected => GetConnectedStatusText(),
ConnectionState.Connecting => "<color=yellow>●</color> Connecting...",
@@ -154,16 +142,8 @@ namespace eagle {
return "<color=yellow>●</color> Reconnecting...";
}
int secondsRemaining = Math.Min(10, (int)Math.Ceiling(timeUntilRetry.TotalSeconds));
int secondsRemaining = (int)Math.Ceiling(timeUntilRetry.TotalSeconds);
return $"<color=orange>●</color> Retry in {secondsRemaining}s";
}
private void SetRetryButtonVisible(bool visible) {
if (retryButton != null) { retryButton.gameObject.SetActive(visible); }
}
private void OnRetryClicked() {
if (_connection != null) { _connection.ForceReconnect(); }
}
}
}
@@ -229,11 +229,12 @@ namespace eagle {
void OnApplicationPause(bool pause) {
if (ModelUpdater == null) { return; }
// Don't unsubscribe on pause - this caused a race condition where the subscriber
// could be lost if pause happened before the async StartListeningForUpdates completed.
// With MainQueue rate-limiting, keeping the subscription during pause is safe.
// Reconnects will continue to work, and updates will queue up and be processed on
// resume.
if (pause) {
ModelUpdater.StopListeningForUpdates();
} else {
// Fire-and-forget - subscription is awaited internally and failures are logged
_ = ModelUpdater.StartListeningForUpdates();
}
}
#if UNITY_EDITOR
@@ -43,7 +43,7 @@ namespace eagle {
TokenId? CommandToken { get; }
TokenId LastPostedToken { get; }
ConcurrentDictionary<String, ShardokGameModel> ShardokGameModels { get; }
Dictionary<String, ShardokGameModel> ShardokGameModels { get; }
private bool ShardokGameModelIsRunning(ShardokGameModel sgm) =>
sgm.GameStatus.State == GameStatus.Types.State.GameRunning
@@ -105,9 +105,10 @@ namespace eagle {
_currentModel.ShardokGameModels
.Select(sgm => {
var needsResync = _shardokNeedsResync.GetValueOrDefault(sgm.Key, false);
// Use thread-safe count from gRPC thread updates. Fall back to 0 if
// not yet tracked (avoids accessing non-thread-safe History.Count).
var count = _shardokResultCounts.GetValueOrDefault(sgm.Key, 0);
// Use thread-safe count from gRPC thread updates, fall back to model
var count = _shardokResultCounts.GetValueOrDefault(
sgm.Key,
sgm.Value.History.Count());
return new IClientConnectionSubscriber.ShardokViewStatus {
shardokGameId = sgm.Key,
filteredResultCount = needsResync ? 0 : count,
@@ -198,8 +199,7 @@ namespace eagle {
return new List<AvailableCommand>();
}
// Thread-safe: accessed from heartbeat timer thread via ShardokViewStatuses
public ConcurrentDictionary<String, ShardokGameModel> ShardokGameModels { get; set; }
public Dictionary<String, ShardokGameModel> ShardokGameModels { get; set; }
public FactionView MaybeDestroyedFaction(FactionId factionId) {
if (ActiveFactions.TryGetValue(factionId, out var factionView)) {
@@ -241,8 +241,7 @@ namespace eagle {
new Dictionary<ProvinceId, OneProvinceAvailableCommands>();
_currentModel.GsView = new GameStateView();
_currentModel.ShardokGameModels =
new ConcurrentDictionary<ShardokGameId, ShardokGameModel>();
_currentModel.ShardokGameModels = new Dictionary<ShardokGameId, ShardokGameModel>();
_currentModel.BattalionTypes = new Dictionary<BattalionTypeId, BattalionType>();
@@ -311,36 +310,18 @@ namespace eagle {
// Store server-reported game status for UI
if (updateItem.ActionResultResponse.ServerGameStatus != null) {
ServerStatus = updateItem.ActionResultResponse.ServerGameStatus;
_connectionLogger.LogLine(
$"[UPDATE] ServerGameStatus updated: {ServerStatus.Status}");
}
// Note: _lastUnfilteredResultCount is updated on the gRPC thread in
// UpdateResultCounts() before enqueueing. We don't update it here to avoid
// race conditions where a backlogged MainQueue update overwrites a newer count.
var hasResults = updateItem.ActionResultResponse.ActionResultViews.Any();
var hasCommands = updateItem.ActionResultResponse.AvailableCommands != null;
var incomingToken =
hasCommands ? updateItem.ActionResultResponse.AvailableCommands.Token
: -1;
var tokenMatches = hasCommands && incomingToken == _currentModel.CommandToken;
_connectionLogger.LogLine(
$"[UPDATE] hasResults={hasResults}, hasCommands={hasCommands}, " +
$"incomingToken={incomingToken}, currentToken={_currentModel.CommandTokenString}, " +
$"tokenMatches={tokenMatches}");
if (hasResults || !hasCommands || !tokenMatches) {
_lastUnfilteredResultCount =
updateItem.ActionResultResponse.UnfilteredResultCountAfter;
if (updateItem.ActionResultResponse.ActionResultViews.Any() ||
updateItem.ActionResultResponse.AvailableCommands == null ||
updateItem.ActionResultResponse.AvailableCommands.Token !=
_currentModel.CommandToken) {
HandleUpdates(updateItem.ActionResultResponse.ActionResultViews.ToList());
HandleAvailableCommands(updateItem.ActionResultResponse.AvailableCommands);
if (UpdateAction != null) UpdateAction.Invoke(_currentModel);
_connectionLogger.LogLine(
"[UPDATE] Processed update and invoked UpdateAction");
} else {
_connectionLogger.LogLine(
"[UPDATE] SKIPPED - no results, has commands, token matches");
}
break;
@@ -358,9 +339,7 @@ namespace eagle {
// Battle may have ended before we could create the model - remove
// any stale reference and skip this update
if (shardokGameModel == null) {
_currentModel.ShardokGameModels.TryRemove(
oneResponse.ShardokGameId,
out _);
_currentModel.ShardokGameModels.Remove(oneResponse.ShardokGameId);
_shardokResultCounts.TryRemove(oneResponse.ShardokGameId, out _);
continue;
}
@@ -389,9 +368,7 @@ namespace eagle {
shardokGameModel;
} else {
// Game ended - remove from active models so UI knows battle is over
_currentModel.ShardokGameModels.TryRemove(
oneResponse.ShardokGameId,
out _);
_currentModel.ShardokGameModels.Remove(oneResponse.ShardokGameId);
_shardokResultCounts.TryRemove(oneResponse.ShardokGameId, out _);
}
}
@@ -421,14 +398,9 @@ namespace eagle {
public void UpdateResultCounts(GameUpdate update) {
switch (update.GameUpdateDetailsCase) {
case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
var newCount = update.ActionResultResponse.UnfilteredResultCountAfter;
lock (_resultCountLock) {
var oldCount = _lastUnfilteredResultCount;
_lastUnfilteredResultCount = newCount;
if (newCount != oldCount) {
_connectionLogger.LogLine(
$"[RESULT_COUNT] Updated count {oldCount} -> {newCount}");
}
_lastUnfilteredResultCount =
update.ActionResultResponse.UnfilteredResultCountAfter;
}
break;
@@ -506,31 +478,6 @@ namespace eagle {
_currentModel.BattalionTypes =
startingState.BattalionTypes.ToDictionary(bt => bt.TypeId, bt => bt);
_currentModel.ChronicleEntries = startingState.ChronicleEntries.ToList();
// For any outstanding battles not in ShardokGameModels, create models and mark for
// resync. This ensures fresh clients get Shardok state for ongoing battles.
bool needsResubscribe = false;
foreach (var battle in _currentModel.ShardokBattles) {
if (!_currentModel.ShardokGameModels.ContainsKey(battle.ShardokGameId)) {
var model = MakeGameModel(battle.ShardokGameId);
if (model != null) {
_currentModel.ShardokGameModels[battle.ShardokGameId] = model;
MarkShardokForResync(battle.ShardokGameId);
_connectionLogger.LogLine(
$"[STATE_RESYNC] Created ShardokGameModel for outstanding battle {battle.ShardokGameId}");
needsResubscribe = true;
}
}
}
// If we created new ShardokGameModels, re-subscribe to request their full state.
// The original subscribe didn't include these battles since we didn't know about them
// yet.
if (needsResubscribe) {
_connectionLogger.LogLine(
"[STATE_RESYNC] Re-subscribing to request full Shardok state for new battles");
_ = StartListeningForUpdates();
}
}
private void HandleUpdates(List<ActionResultView> results) {
@@ -833,7 +780,7 @@ namespace eagle {
// the Eagle update removing the battle arrives before the Shardok Victory update.
if (_currentModel.ShardokGameModels.TryGetValue(rb, out var sgm)) {
sgm.MarkBattleEnded("Battle has ended.");
_currentModel.ShardokGameModels.TryRemove(rb, out _);
_currentModel.ShardokGameModels.Remove(rb);
}
_shardokResultCounts.TryRemove(rb, out _);
@@ -132,10 +132,7 @@ namespace eagle {
public void ProvinceHovered(ProvinceId? pid) {
// Highlight moving armies table
// Check row count to avoid index out of range if data changed after table was built
var rowCount = movingArmiesTable.RowCount;
MovingArmies.Each((army, i) => {
if (i >= rowCount) return;
var row = movingArmiesTable.ComponentAt<MovingArmyTableRow>(i);
if (army.DestinationProvinceId == pid || army.OriginProvinceId == pid) {
row.ShadeOn();
@@ -57,8 +57,7 @@ namespace eagle.Notifications {
{ BreakAllianceAcceptedDetails, BreakAllianceAcceptedNotificationGenerator.Generator },
{ VassalExiledDetails, VassalExiledDetailsNotificationGenerator.Generator },
{ WithdrewForTruce, WithdrewForTruceDetailsNotificationGenerator.Generator },
{ ProfessionGainedDetails, ProfessionGainedDetailsNotificationGenerator.Generator },
{ NewFactionHeadDetails, NewFactionHeadDetailsNotificationGenerator.Generator }
{ ProfessionGainedDetails, ProfessionGainedDetailsNotificationGenerator.Generator }
};
public static IEnumerable<Notification> GenerateNotifications(
@@ -1,98 +0,0 @@
using System;
using System.Collections.Generic;
using Net.Eagle0.Eagle.Common;
using Net.Eagle0.Eagle.Views;
namespace eagle.Notifications.ARNNotifications {
using ProvinceId = Int32;
using HeroId = Int32;
public static class NewFactionHeadDetailsNotificationGenerator {
public static readonly ARNNotificationGenerator Generator = GenerateNotifications;
private static readonly Random Random = new();
private static readonly List<string> NotificationTitles = new() {
"New Leadership",
"Succession",
"A New Era",
"Rise to Power",
"The Mantle Passes",
"Heir Ascendant",
"Crown of Command",
"Leadership Transferred",
"The Torch Passes",
"New Commander"
};
private static string GetNotificationTitle() {
return NotificationTitles[Random.Next(NotificationTitles.Count)];
}
private static ProvinceId? FindProvinceForHero(HeroId heroId, IGameModel model) {
foreach (var province in model.Provinces.Values) {
if (province.FullInfo?.RulingFactionHeroIds.Contains(heroId) == true) {
return province.Id;
}
}
return null;
}
private static IEnumerable<Notification> GenerateNotifications(
Net.Eagle0.Eagle.Common.Notification notification,
IGameModel currentModel) {
var details = notification.Details.NewFactionHeadDetails;
var newHeadHero = currentModel.Heroes[details.NewHeadHeroId];
var previousHeadHero =
currentModel.Heroes.TryGetValue(details.PreviousHeadHeroId, out var prev)
? prev
: null;
string textTemplate;
List<ProvinceId> affectedProvinces;
if (details.FactionId == currentModel.PlayerId) {
// Player's own faction - their new faction head
string previousHeadDescription =
previousHeadHero != null ? $"Following the death of {{PreviousHead}}, your"
: "Your";
textTemplate =
$"{previousHeadDescription} sworn {DisplayNames.SiblingDescription(newHeadHero.PronounGender)} {{NewHead}} now leads your faction.\n\n";
var heroProvinceId = FindProvinceForHero(details.NewHeadHeroId, currentModel);
affectedProvinces = heroProvinceId.HasValue
? new List<ProvinceId> { heroProvinceId.Value }
: currentModel.ProvincesForFaction(details.FactionId);
} else {
// Another faction
var factionName = currentModel.FactionName(details.FactionId);
if (previousHeadHero != null) {
textTemplate =
$"Following the death of {{PreviousHead}}, {{NewHead}} now leads {factionName}.\n\n";
} else {
textTemplate = $"{{NewHead}} now leads {factionName}.\n\n";
}
affectedProvinces = currentModel.ProvincesForFaction(details.FactionId);
}
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "NewHead", (newHeadHero.NameTextId, "A hero") }
};
if (previousHeadHero != null) {
heroPlaceholders["PreviousHead"] =
(previousHeadHero.NameTextId, "the previous leader");
}
var displayedHeroes = new List<HeroView> { newHeadHero };
if (previousHeadHero != null) { displayedHeroes.Add(previousHeadHero); }
yield return DynamicTextNotification.StreamingDynamicNotification(
title: GetNotificationTitle(),
textTemplate: textTemplate,
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: affectedProvinces,
displayedHeroes: displayedHeroes);
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: e9afdd2068b294a29993f07b379eb9c3
@@ -1,106 +1,30 @@
using System;
using System.Collections.Generic;
using Net.Eagle0.Eagle.Common;
using Net.Eagle0.Eagle.Views;
namespace eagle.Notifications.ARNNotifications {
using ProvinceId = Int32;
using HeroId = Int32;
public static class ProfessionGainedDetailsNotificationGenerator {
public static readonly ARNNotificationGenerator Generator = GenerateNotifications;
private static readonly Random Random = new();
private static readonly Dictionary<Profession, string> ProfessionNames =
new Dictionary<Profession, string> {
{ Profession.Mage, "Mage" },
{ Profession.Necromancer, "Necromancer" },
{ Profession.Engineer, "Engineer" },
{ Profession.Paladin, "Paladin" },
{ Profession.Ranger, "Ranger" },
{ Profession.Champion, "Champion" }
};
private static readonly Dictionary<Profession, string> ProfessionNames = new() {
{ Profession.Mage, "Mage" },
{ Profession.Necromancer, "Necromancer" },
{ Profession.Engineer, "Engineer" },
{ Profession.Paladin, "Paladin" },
{ Profession.Ranger, "Ranger" },
{ Profession.Champion, "Champion" }
};
private static readonly Dictionary<Profession, List<string>> ProfessionTitles = new() {
{ Profession.Mage,
new List<string> {
"Arcane Awakening",
"The Gift Revealed",
"Touched by Magic",
"Mystical Ascension",
"Power Unbound",
"The Arcane Path",
"Secrets of the Weave",
"Spellborn",
"Wielder of the Unseen",
"Flames of Knowledge"
} },
{ Profession.Necromancer,
new List<string> {
"Dark Pact Sealed",
"Beyond the Veil",
"Death's Apprentice",
"Whispers from Beyond",
"The Forbidden Art",
"Shadow Covenant",
"Secrets of the Grave",
"Communion with Darkness",
"The Deathless Path",
"Embrace of Shadow"
} },
{ Profession.Engineer,
new List<string> {
"Genius Unleashed",
"Master of Mechanisms",
"The Inventor's Spark",
"Gears of Progress",
"Mind of Innovation",
"Builder of Wonders",
"The Tinkerer's Art",
"Siege Mastery",
"Architect of War",
"Mechanical Brilliance"
} },
{ Profession.Paladin,
new List<string> {
"Divine Calling",
"Holy Vows Taken",
"Blessed Champion",
"The Righteous Path",
"Shield of the Faith",
"Anointed Warrior",
"Oath of Light",
"Heaven's Chosen",
"Sacred Duty",
"Defender of the Realm"
} },
{ Profession.Ranger,
new List<string> {
"One with the Wild",
"Voice of the Forest",
"The Untamed Path",
"Nature's Guardian",
"Shadow of the Woods",
"Hunter's Instinct",
"Wild Heart",
"The Wanderer's Way",
"Beast Companion",
"Eyes of the Hawk"
} },
{ Profession.Champion,
new List<string> {
"Born for Battle",
"Blade Mastery",
"The Warrior's Edge",
"Forged in Combat",
"Unmatched Prowess",
"Heart of Steel",
"The Victor's Path",
"Legend in the Making",
"Master of Arms",
"Glory Awaits"
} }
};
private static readonly Dictionary<Profession, string> ProfessionTitles =
new Dictionary<Profession, string> {
{ Profession.Mage, "Arcane Awakening" },
{ Profession.Necromancer, "Dark Pact Sealed" },
{ Profession.Engineer, "Genius Unleashed" },
{ Profession.Paladin, "Divine Calling" },
{ Profession.Ranger, "One with the Wild" },
{ Profession.Champion, "Born for Battle" }
};
private static string GetProfessionName(Profession profession) {
return ProfessionNames.TryGetValue(profession, out var name) ? name : "Unknown";
@@ -116,19 +40,8 @@ namespace eagle.Notifications.ARNNotifications {
}
private static string GetProfessionTitle(Profession profession) {
if (ProfessionTitles.TryGetValue(profession, out var titles) && titles.Count > 0) {
return titles[Random.Next(titles.Count)];
}
return "Profession Gained";
}
private static ProvinceId? FindProvinceForHero(HeroId heroId, IGameModel model) {
foreach (var province in model.Provinces.Values) {
if (province.FullInfo?.RulingFactionHeroIds.Contains(heroId) == true) {
return province.Id;
}
}
return null;
return ProfessionTitles.TryGetValue(profession, out var title) ? title
: "Profession Gained";
}
private static IEnumerable<Notification> GenerateNotifications(
@@ -136,38 +49,14 @@ namespace eagle.Notifications.ARNNotifications {
IGameModel currentModel) {
var details = notification.Details.ProfessionGainedDetails;
var hero = currentModel.Heroes[details.HeroId];
var factionName = currentModel.FactionName(details.FactionId);
var professionName = GetProfessionName(details.NewProfession);
var article = GetArticle(professionName);
string textTemplate;
List<ProvinceId> affectedProvinces;
var affectedProvinces = currentModel.ProvincesForFaction(details.FactionId);
if (details.FactionId == currentModel.PlayerId) {
// Player's own hero - vary text based on faction leader status
string heroDescription;
if (hero.IsFactionLeader) {
heroDescription =
$"Your sworn {DisplayNames.SiblingDescription(hero.PronounGender)}";
} else {
heroDescription = "Your vassal";
}
var heroProvinceId = FindProvinceForHero(details.HeroId, currentModel);
if (heroProvinceId.HasValue) {
var provinceName = currentModel.Provinces[heroProvinceId.Value].Name;
textTemplate =
$"{heroDescription} {{Hero}} in {provinceName} became {article} {professionName}.\n\n";
affectedProvinces = new List<ProvinceId> { heroProvinceId.Value };
} else {
textTemplate =
$"{heroDescription} {{Hero}} became {article} {professionName}.\n\n";
affectedProvinces = currentModel.ProvincesForFaction(details.FactionId);
}
} else {
// Another faction's hero
var factionName = currentModel.FactionName(details.FactionId);
textTemplate = $"{{Hero}} of {factionName} became {article} {professionName}.\n\n";
affectedProvinces = currentModel.ProvincesForFaction(details.FactionId);
}
string textTemplate =
$"{{Hero}} of {factionName} became {article} {professionName}.\n\n";
var heroPlaceholders = new Dictionary<string, (string nameTextId, string fallback)> {
{ "Hero", (hero.NameTextId, "A hero") }
@@ -31,13 +31,7 @@ namespace eagle.Notifications.ARNNotifications {
};
if (playerId.HasValue && playerId.Value == paidToFactionId) {
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ransom Accepted",
textTemplate: $"We have accepted the ransom from {currentModel.FactionName(paidByFactionId)} for {{RansomedHero}}.\n\n",
heroPlaceholders: heroPlaceholders,
llmId: notification.LlmId,
provinceIds: new List<ProvinceId>(),
displayedHeroes: new List<HeroView> { ransomedHero, offeringFactionHead });
// no notification
} else if (playerId.HasValue && playerId.Value == paidByFactionId) {
yield return DynamicTextNotification.StreamingDynamicNotification(
title: "Ransom Accepted",
@@ -9,7 +9,6 @@ namespace eagle.Notifications {
private List<GeneratedTextListener> textListeners = new();
private string textTemplate;
private Dictionary<string, string> placeholderValues = new();
private Dictionary<string, string> fallbackValues = new();
public DynamicTextNotification(
string title,
@@ -73,9 +72,6 @@ namespace eagle.Notifications {
string nameTextId = kvp.Value.nameTextId;
string fallback = kvp.Value.fallback;
// Store fallback so UpdateText can use it for missing placeholders
fallbackValues[placeholder] = fallback;
if (!string.IsNullOrEmpty(nameTextId)) {
var listener = new GeneratedTextListener(
nameTextId,
@@ -99,15 +95,6 @@ namespace eagle.Notifications {
private void UpdateText() {
string result = textTemplate;
// First apply fallbacks for any placeholder without a loaded value
foreach (var kvp in fallbackValues) {
if (!placeholderValues.ContainsKey(kvp.Key)) {
result = result.Replace($"{{{kvp.Key}}}", kvp.Value);
}
}
// Then apply actual loaded values
foreach (var kvp in placeholderValues) {
result = result.Replace($"{{{kvp.Key}}}", kvp.Value);
}
@@ -44,12 +44,6 @@ namespace eagle {
private readonly Logger _remoteEagleClientLogger = Logger.GetLogger("ConnectionLogger");
private readonly Logger _timingsLogger = Logger.GetLogger("ConnectionLogger");
/// <summary>Timestamped log for connection flow tracing.</summary>
private void LogFlow(string message) {
var ts = DateTime.UtcNow.ToString("HH:mm:ss.fff");
_remoteEagleClientLogger.LogLine($"[FLOW {ts}] {message}");
}
private volatile bool _isConnecting = false;
private Timer _idleCheckTimer = null;
private Timer _heartbeatTimer = null;
@@ -75,17 +69,6 @@ namespace eagle {
private ConnectionCircuitBreaker _circuitBreaker = new ConnectionCircuitBreaker();
public ConnectionCircuitBreaker CircuitBreaker => _circuitBreaker;
/// <summary>
/// Force an immediate reconnection attempt, bypassing the circuit breaker timeout.
/// </summary>
public void ForceReconnect() {
_circuitBreaker.ForceReconnect();
_retryTimer?.Dispose();
NextReconnectAttempt = null;
LogConnectionEvent("force_reconnect", "User requested immediate reconnect");
Task.Run(() => Connect());
}
private DateTime? GetDeadlineFromNow() {
return DateTime.UtcNow.AddSeconds(TimeoutSeconds);
}
@@ -128,7 +111,6 @@ namespace eagle {
_currentState = ConnectionState.Reconnecting;
NextReconnectAttempt = DateTime.UtcNow.AddSeconds(backoffSeconds);
LogFlow($"SCHEDULE_RECONNECT reason={reason} backoff={backoffSeconds:F1}s attempt={_consecutiveFailures}");
LogConnectionEvent(
"schedule_reconnect",
$"{reason}, backoff={backoffSeconds:F1}s, attempt={_consecutiveFailures}");
@@ -201,14 +183,16 @@ namespace eagle {
var ackTcs = new TaskCompletionSource<SubscriptionAck>();
lock (_pendingSubscriptionAcks) { _pendingSubscriptionAcks[gameId] = ackTcs; }
var shardokCount = shardokStatuses.Count();
LogFlow($"SUBSCRIBE game={gameId} unfilteredCount={subscriber.LastUnfilteredResultCount} shardokGames={shardokCount}");
_remoteEagleClientLogger.LogLine(
$"[SUBSCRIBE] Sending StreamGameRequest for game {gameId}, " +
$"unfilteredCount={subscriber.LastUnfilteredResultCount}");
var sendSuccess = await DoWithStreamingCall(async (sc) => {
await sc.RequestStream.WriteAsync(request).ConfigureAwait(false);
LogFlow($"SUBSCRIBE sent for game={gameId}");
return true;
}).ConfigureAwait(false);
await sc.RequestStream.WriteAsync(request);
_remoteEagleClientLogger.LogLine(
$"[SUBSCRIBE] StreamGameRequest sent successfully for game {gameId}");
return true;
});
if (!sendSuccess) {
// Write failed - clean up and return
@@ -222,7 +206,7 @@ namespace eagle {
try {
var ackTask = ackTcs.Task;
var timeoutTask = Task.Delay(SubscriptionAckTimeoutMs, timeoutCts.Token);
var completedTask = await Task.WhenAny(ackTask, timeoutTask).ConfigureAwait(false);
var completedTask = await Task.WhenAny(ackTask, timeoutTask);
if (completedTask == timeoutTask) {
// Timeout waiting for ack
@@ -236,15 +220,24 @@ namespace eagle {
// Cancel the timeout task since ack was received
timeoutCts.Cancel();
var ack = await ackTask.ConfigureAwait(false);
var ack = await ackTask;
if (ack.Success) {
LogFlow($"SUBSCRIBE_ACK game={gameId} success=true confirmedCount={ack.ConfirmedResultCount}");
_remoteEagleClientLogger.LogLine(
$"[SUBSCRIBE] Subscription confirmed for game {gameId}, " +
$"confirmedResultCount={ack.ConfirmedResultCount}");
// Note: Shardok resync flags are cleared in EagleGameModel.HandleOneGameUpdate
// AFTER updates are actually received, not here. This ensures that if the
// connection drops between acknowledgment and update delivery, the resync
// will be requested again on the next reconnect.
return true;
} else {
LogFlow($"SUBSCRIBE_ACK game={gameId} success=false error={ack.ErrorMessage}");
LogConnectionEvent(
"subscribe_ack_failed",
$"game={gameId}, error={ack.ErrorMessage}");
_remoteEagleClientLogger.LogLine(
$"[SUBSCRIBE] Server rejected subscription for game {gameId}: {ack.ErrorMessage}");
return false;
}
} catch (OperationCanceledException) {
@@ -262,11 +255,9 @@ namespace eagle {
}
public async Task Connect() {
LogFlow($"Connect() called, state={_currentState}, failures={_consecutiveFailures}");
// Prevent concurrent connection attempts
if (_isConnecting) {
LogFlow("Connect() skipped - already connecting");
_remoteEagleClientLogger.LogLine($"Connect() skipped - already connecting");
return;
}
@@ -284,7 +275,6 @@ namespace eagle {
_currentState = _consecutiveFailures > 0 ? ConnectionState.Reconnecting
: ConnectionState.Connecting;
NextReconnectAttempt = null;
LogFlow($"State -> {_currentState}");
LogConnectionEvent("connect_attempt");
// Dispose existing streaming call before creating new one
@@ -317,19 +307,13 @@ namespace eagle {
// Stream subscriptions OUTSIDE lock (can await)
// Set state to SubscriptionPending while waiting for acks
LogFlow($"Stream created, {subscribersToStream.Count} subscribers to stream");
if (subscribersToStream.Any()) {
_currentState = ConnectionState.SubscriptionPending;
LogFlow($"State -> {_currentState}");
}
bool allSucceeded = true;
foreach (var subscriber in subscribersToStream) {
LogFlow($"Subscribing game {subscriber.GameId}...");
if (!await StreamOneGameAsync(subscriber)) {
LogFlow($"Subscription FAILED for game {subscriber.GameId}");
allSucceeded = false;
}
if (!await StreamOneGameAsync(subscriber)) { allSucceeded = false; }
}
if (!allSucceeded) {
@@ -358,7 +342,6 @@ namespace eagle {
_lastSuccessfulConnect = DateTime.UtcNow;
_consecutiveFailures = 0; // Reset backoff on successful connection
_currentState = ConnectionState.Connected;
LogFlow($"State -> {_currentState} (all subscriptions succeeded)");
_circuitBreaker.RecordSuccess();
LogConnectionEvent("connect_success");
@@ -476,8 +459,8 @@ namespace eagle {
break;
}
}
// Note: PostRequest is called inside each case, not here
// (removed duplicate PostRequest call that was causing double-posting)
await PostRequest(nextCommand);
}
}
@@ -509,9 +492,6 @@ namespace eagle {
}
public void Dispose() {
Thread threadToJoin = null;
CancellationTokenSource tokenSourceToDispose = null;
lock (this) {
// Dispose timers first to stop any pending callbacks
StopIdleCheckTimer();
@@ -534,25 +514,20 @@ namespace eagle {
// Clear pending commands
_pendingCommands.Clear();
// Cancel thread cancellation token (but don't dispose yet)
// Cancel and dispose thread cancellation token
_threadCancellationTokenSource?.Cancel();
// Capture thread and token source references for cleanup outside lock
threadToJoin = _streamingCallThread;
_streamingCallThread = null;
tokenSourceToDispose = _threadCancellationTokenSource;
// Give thread a chance to exit gracefully
if (_streamingCallThread != null) {
if (!_streamingCallThread.Join(TimeSpan.FromSeconds(2))) {
Console.WriteLine("Warning: Streaming call thread did not exit gracefully");
}
_streamingCallThread = null;
}
_threadCancellationTokenSource?.Dispose();
_threadCancellationTokenSource = null;
}
// Join thread OUTSIDE lock to avoid deadlock - the streaming thread
// may be trying to acquire lock(this) when we're waiting for it
if (threadToJoin != null) {
if (!threadToJoin.Join(TimeSpan.FromSeconds(2))) {
Console.WriteLine("Warning: Streaming call thread did not exit gracefully");
}
}
tokenSourceToDispose?.Dispose();
}
public void SetLobbySubscriber(ILobbySubscriber subscriber) {
@@ -576,18 +551,16 @@ namespace eagle {
public async Task PostError(EagleGameId gameId, string errorMessage, string stackTrace) {
await DoWithStreamingCall(async (streamingCall) => {
await streamingCall.RequestStream
.WriteAsync(new UpdateStreamRequest {
ErrorRequest =
new ErrorRequest {
ErrorMessage = errorMessage,
GameId = gameId,
StackTrace = stackTrace
}
})
.ConfigureAwait(false);
await streamingCall.RequestStream.WriteAsync(new UpdateStreamRequest {
ErrorRequest =
new ErrorRequest {
ErrorMessage = errorMessage,
GameId = gameId,
StackTrace = stackTrace
}
});
return true;
}).ConfigureAwait(false);
});
}
public void Unsubscribe(IClientConnectionSubscriber subscriber) {
@@ -599,11 +572,10 @@ namespace eagle {
lock (this) { _pendingCommands.Add(request); }
await DoWithStreamingCall(async (streamingCall) => {
await streamingCall.RequestStream
.WriteAsync(new UpdateStreamRequest { PostCommandRequest = request })
.ConfigureAwait(false);
await streamingCall.RequestStream.WriteAsync(
new UpdateStreamRequest { PostCommandRequest = request });
return true;
}).ConfigureAwait(false);
});
lock (this) { _pendingCommands.Remove(request); }
} catch (Exception e) { Console.WriteLine("Failed to post command: " + e); }
}
@@ -684,20 +656,8 @@ namespace eagle {
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> call);
private async Task<bool> DoWithStreamingCall(WithStreamingCallDelegate action) {
try {
var actionTask = action(_streamingCall);
var timeoutTask = Task.Delay(WriteTimeoutMs, _cancellationToken);
var completedTask =
await Task.WhenAny(actionTask, timeoutTask).ConfigureAwait(false);
if (completedTask == timeoutTask) {
_remoteEagleClientLogger.LogLine(
"[WRITE] DoWithStreamingCall timed out - connection may be dead");
return false;
}
return await actionTask.ConfigureAwait(false);
} catch (OperationCanceledException) { return false; } catch (RpcException e) {
return await action(_streamingCall);
} catch (RpcException e) {
if (e.StatusCode == StatusCode.Cancelled) {
// This is expected when the connection is closed.
return false;
@@ -707,9 +667,6 @@ namespace eagle {
}
}
// Timeout for WriteAsync to prevent ThreadPool exhaustion from blocked writes
private const int WriteTimeoutMs = 10000;
public async Task<bool> SendUpdateStreamRequestAsync(UpdateStreamRequest request) {
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> sc = null;
lock (this) {
@@ -718,18 +675,8 @@ namespace eagle {
}
try {
// Use timeout to prevent indefinite blocking on dead connections
using var timeoutCts =
CancellationTokenSource.CreateLinkedTokenSource(_cancellationToken);
timeoutCts.CancelAfter(WriteTimeoutMs);
await sc.RequestStream.WriteAsync(request, timeoutCts.Token).ConfigureAwait(false);
await sc.RequestStream.WriteAsync(request, _cancellationToken);
return true;
} catch (OperationCanceledException) {
// Timeout or cancellation - connection is likely dead
_remoteEagleClientLogger.LogLine(
"[WRITE] WriteAsync timed out or cancelled - connection may be dead");
return false;
} catch (RpcException e) {
if (e.StatusCode == StatusCode.Cancelled) {
// This is expected when the connection is closed.
@@ -748,7 +695,7 @@ namespace eagle {
private void HandleGameUpdate(GameUpdate gameUpdate, DateTime receivedTime) {
switch (gameUpdate.GameUpdateDetailsCase) {
case GameUpdate.GameUpdateDetailsOneofCase.ErrorResponse:
LogFlow($"UPDATE ErrorResponse game={gameUpdate.GameId}");
_remoteEagleClientLogger.LogLine("Got an error response!");
MainQueue.Q.Enqueue(() => {
lock (this) { _subscribers.Remove(gameUpdate.GameId); }
});
@@ -756,6 +703,7 @@ namespace eagle {
case GameUpdate.GameUpdateDetailsOneofCase.StreamingTextResponse:
// Handle streaming text directly on gRPC thread - dictionary is thread-safe.
// Listeners are notified later via ProcessPendingUpdates() on main thread.
var str = gameUpdate.StreamingTextResponse;
if (str != null) {
ClientTextProvider.Provider.HandleNewStreamingText(
@@ -764,22 +712,15 @@ namespace eagle {
str.StartingByteCount,
str.Completed);
}
// No MainQueue enqueue needed - ProcessPendingUpdates handles listener
// notification
break;
case GameUpdate.GameUpdateDetailsOneofCase.ActionResultResponse:
var arResp = gameUpdate.ActionResultResponse;
var arCount = arResp.ActionResultViews?.Count ?? 0;
var hasStartingState = gameUpdate.StartingState != null;
var hasCommands = arResp.AvailableCommands != null;
var cmdToken = hasCommands ? arResp.AvailableCommands.Token : -1;
var cmdCount = hasCommands
? arResp.AvailableCommands.CommandsByProvince?.Count ?? 0
: 0;
var status = arResp.ServerGameStatus?.Status.ToString() ?? "null";
LogFlow($"UPDATE ActionResult game={gameUpdate.GameId} actionResults={arCount} " +
$"startingState={hasStartingState} hasCommands={hasCommands} " +
$"token={cmdToken} cmdProvinces={cmdCount} status={status}");
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
// Update result counts IMMEDIATELY on the gRPC thread, before enqueueing.
// This ensures reconnects use accurate counts even when MainQueue is blocked
// (e.g., when Unity is backgrounded).
lock (this) {
if (_subscribers.TryGetValue(gameUpdate.GameId, out var sub)) {
sub.UpdateResultCounts(gameUpdate);
@@ -795,38 +736,19 @@ namespace eagle {
_subscribers.Remove(gameUpdate.GameId);
}
}
await TryPendingCommands();
});
break;
case GameUpdate.GameUpdateDetailsOneofCase.ShardokActionResultResponse:
var shResp = gameUpdate.ShardokActionResultResponse;
var shGames = shResp.ShardokGameResponses?.Count ?? 0;
LogFlow($"UPDATE ShardokResult game={gameUpdate.GameId} shardokGames={shGames}");
lock (this) {
if (_subscribers.TryGetValue(gameUpdate.GameId, out var sub2)) {
sub2.UpdateResultCounts(gameUpdate);
var processTime = (DateTime.UtcNow - receivedTime).TotalMilliseconds;
if (processTime > 100.0) {
_timingsLogger.LogLine($"PROCESS {processTime} ms");
}
}
MainQueue.Q.Enqueue(async () => {
IClientConnectionSubscriber subscriber;
lock (this) {
if (_subscribers.TryGetValue(gameUpdate.GameId, out subscriber)) {
subscriber.ReceiveGameUpdate(gameUpdate);
} else {
_subscribers.Remove(gameUpdate.GameId);
}
}
await TryPendingCommands();
});
break;
}
}
private async void HandleStreamingCall() {
LogFlow("HandleStreamingCall STARTED");
try {
AsyncDuplexStreamingCall<UpdateStreamRequest, UpdateStreamResponse> sc;
CancellationToken threadToken;
@@ -942,10 +864,8 @@ namespace eagle {
waitStartTime = DateTime.UtcNow;
}
// While loop exited normally (not via exception)
var scNull = sc == null;
var tokenCancelled = _currentThreadToken.IsCancellationRequested;
LogFlow($"HandleStreamingCall ENDED normally: sc_null={scNull} token_cancelled={tokenCancelled}");
_remoteEagleClientLogger.LogLine(
"How did we get here? This is not my beautiful wife!");
} catch (RpcException e) {
lock (this) {
_lastDisconnect = DateTime.UtcNow;
@@ -1043,13 +963,7 @@ namespace eagle {
AutoReset = true,
Interval = 5000 // Check every 5 seconds
};
_idleCheckTimer.Elapsed += (sender, args) => {
try {
CheckForIdleTimeout();
} catch (Exception e) {
LogFlow($"IDLE_TIMER_ERROR {e.GetType().Name}: {e.Message}");
}
};
_idleCheckTimer.Elapsed += (sender, args) => CheckForIdleTimeout();
_idleCheckTimer.Enabled = true;
}
@@ -1062,16 +976,10 @@ namespace eagle {
}
private void CheckForIdleTimeout() {
var idleTime = (DateTime.UtcNow - _lastResponseReceived).TotalSeconds;
// Log every check when idle > 5s to diagnose timer issues
if (idleTime > 5.0) {
_remoteEagleClientLogger.LogLine(
$"[IDLE_CHECK] idle_time={idleTime:F1}s, cancelled={_cancellationToken.IsCancellationRequested}");
}
if (_cancellationToken.IsCancellationRequested) { return; }
var idleTime = (DateTime.UtcNow - _lastResponseReceived).TotalSeconds;
// Early warnings at 10s and 20s to help diagnose slow connections
if (idleTime > 20.0 && _lastIdleWarningLevel < 2) {
_lastIdleWarningLevel = 2;
@@ -1103,19 +1011,7 @@ namespace eagle {
StopHeartbeatTimer();
_heartbeatTimer =
new Timer { AutoReset = true, Interval = HeartbeatIntervalSeconds * 1000 };
_heartbeatTimer.Elapsed += (sender, args) => {
try {
Task.Run(async () => {
try {
await SendHeartbeat().ConfigureAwait(false);
} catch (Exception e) {
LogFlow($"HEARTBEAT_ERROR {e.GetType().Name}: {e.Message}");
}
});
} catch (Exception e) {
LogFlow($"HEARTBEAT_TIMER_ERROR {e.GetType().Name}: {e.Message}");
}
};
_heartbeatTimer.Elapsed += (sender, args) => Task.Run(() => SendHeartbeat());
_heartbeatTimer.Enabled = true;
}
@@ -1128,16 +1024,8 @@ namespace eagle {
}
private async Task SendHeartbeat() {
LogFlow($"HEARTBEAT_START state={_currentState}");
if (_cancellationToken.IsCancellationRequested) {
LogFlow("HEARTBEAT_SKIP reason=cancellation_requested");
return;
}
if (_currentState != ConnectionState.Connected) {
LogFlow($"HEARTBEAT_SKIP reason=not_connected state={_currentState}");
return;
}
if (_cancellationToken.IsCancellationRequested) { return; }
if (_currentState != ConnectionState.Connected) { return; }
// Build sync status for all subscribed games
var heartbeatRequest = new HeartbeatRequest {
@@ -1167,70 +1055,56 @@ namespace eagle {
var sent = await SendUpdateStreamRequestAsync(request);
if (sent) {
var gameInfo = string.Join(
", ",
heartbeatRequest.GameSyncStatuses.Select(
g => $"{g.GameId}:{g.UnfilteredResultCount}"));
LogFlow($"HEARTBEAT_SENT games={heartbeatRequest.GameSyncStatuses.Count} ({gameInfo})");
} else {
LogFlow("HEARTBEAT_FAILED stream_unavailable");
_remoteEagleClientLogger.LogLine(
$"[HEARTBEAT] Sent heartbeat with {heartbeatRequest.GameSyncStatuses.Count} games");
}
}
// Grace period after connect before sync mismatch triggers reconnect.
// Allows time to receive and process historical results after fresh subscribe.
private const double SyncMismatchGracePeriodSeconds = 60.0;
private void HandleHeartbeatResponse(HeartbeatResponse response) {
LogFlow($"HEARTBEAT_RESPONSE server_ts={response.ServerTimestamp}");
_remoteEagleClientLogger.LogLine(
$"[HEARTBEAT] Got response, server_timestamp={response.ServerTimestamp}");
// Check for sync mismatches reported by server
bool hasMismatch = false;
foreach (var syncResult in response.GameSyncResults) {
if (!syncResult.EagleInSync) {
LogFlow($"SYNC_MISMATCH game={syncResult.GameId} server_count={syncResult.ServerUnfilteredResultCount}");
hasMismatch = true;
}
if (response.GameSyncResults.Count > 0) {
foreach (var syncResult in response.GameSyncResults) {
if (!syncResult.EagleInSync) {
_remoteEagleClientLogger.LogLine(
$"[SYNC_MISMATCH] Game {syncResult.GameId}: Eagle out of sync, " +
$"server has {syncResult.ServerUnfilteredResultCount} results");
LogConnectionEvent(
"sync_mismatch_eagle",
$"game={syncResult.GameId}, server_count={syncResult.ServerUnfilteredResultCount}");
}
foreach (var shardokResult in syncResult.ShardokSyncResults) {
if (!shardokResult.InSync) {
LogFlow($"SYNC_MISMATCH game={syncResult.GameId} shardok={shardokResult.ShardokGameId} server_count={shardokResult.ServerFilteredResultCount}");
hasMismatch = true;
foreach (var shardokResult in syncResult.ShardokSyncResults) {
if (!shardokResult.InSync) {
_remoteEagleClientLogger.LogLine(
$"[SYNC_MISMATCH] Game {syncResult.GameId}, Shardok {shardokResult.ShardokGameId}: " +
$"out of sync, server has {shardokResult.ServerFilteredResultCount} results");
LogConnectionEvent(
"sync_mismatch_shardok",
$"game={syncResult.GameId}, shardok={shardokResult.ShardokGameId}, " +
$"server_count={shardokResult.ServerFilteredResultCount}");
}
}
}
}
if (!hasMismatch) return;
// Trigger resync by reconnecting - this will re-subscribe with current counts
// and the server will send missing updates
_remoteEagleClientLogger.LogLine(
"[SYNC_MISMATCH] Detected sync mismatch, triggering reconnect to resync");
LogConnectionEvent("sync_mismatch_reconnect", "Triggering reconnect to resync");
// Grace period after connect - allow time to receive historical results
if (_lastSuccessfulConnect.HasValue) {
var timeSinceConnect =
(DateTime.UtcNow - _lastSuccessfulConnect.Value).TotalSeconds;
if (timeSinceConnect < SyncMismatchGracePeriodSeconds) {
_remoteEagleClientLogger.LogLine(
$"[SYNC_MISMATCH] Ignoring mismatch during grace period ({timeSinceConnect:F1}s < {SyncMismatchGracePeriodSeconds}s)");
return;
// Schedule reconnect to resync
lock (this) {
_streamingCall?.Dispose();
_streamingCall = null;
_threadCancellationTokenSource?.Cancel();
}
MarkAllShardokGamesForResync();
CancelAllPendingSubscriptionAcks();
ScheduleReconnect("SyncMismatch");
}
// Trigger resync by reconnecting - this will re-subscribe with current counts
// and the server will send missing updates
_remoteEagleClientLogger.LogLine(
"[SYNC_MISMATCH] Detected sync mismatch, triggering reconnect to resync");
LogConnectionEvent("sync_mismatch_reconnect", "Triggering reconnect to resync");
// Schedule reconnect to resync
// Stop timers FIRST to prevent them firing during reconnection
StopHeartbeatTimer();
StopIdleCheckTimer();
lock (this) {
_streamingCall?.Dispose();
_streamingCall = null;
_threadCancellationTokenSource?.Cancel();
}
MarkAllShardokGamesForResync();
CancelAllPendingSubscriptionAcks();
ScheduleReconnect("SyncMismatch");
}
}
}
@@ -28,7 +28,11 @@ public class EagleConnection : IDisposable {
_channel = GrpcChannel.ForAddress(
"https://" + url,
new GrpcChannelOptions {
HttpHandler = CreateHttpHandler(),
HttpHandler =
new YetAnotherHttpHandler {
Http2Only = true,
Http2KeepAliveInterval = TimeSpan.FromSeconds(KeepAliveSeconds)
},
DisposeHttpClient = true,
LoggerFactory = _loggerFactory,
MaxReceiveMessageSize = null
@@ -38,25 +42,6 @@ public class EagleConnection : IDisposable {
return invoker;
}
/// <summary>
/// Create HTTP handler with timeouts configured for reliable connection management.
/// These settings help detect and recover from dead connections, especially on Windows
/// where firewalls may silently drop HTTP/2 keep-alive pings.
/// </summary>
private static YetAnotherHttpHandler CreateHttpHandler() {
return new YetAnotherHttpHandler {
Http2Only = true,
// Send keep-alive pings every 15 seconds
Http2KeepAliveInterval = TimeSpan.FromSeconds(KeepAliveSeconds),
// Close connection if ping not acknowledged within 5 seconds
Http2KeepAliveTimeout = TimeSpan.FromSeconds(5),
// Continue pinging even when idle to detect dead connections
Http2KeepAliveWhileIdle = true,
// Don't wait forever for initial connection
ConnectTimeout = TimeSpan.FromSeconds(10)
};
}
public EagleConnection(string playerName, string password, string url) {
this.playerName = playerName;
credentials = new Metadata { { "user", playerName } };
File diff suppressed because it is too large Load Diff
@@ -511,7 +511,6 @@ public class HexGrid : MonoBehaviour {
}
public void ClearCellLabels() {
if (cells == null) return;
for (int i = 0; i < width * height; i++) {
cells[i].UpperUnitInfoLabel.text = "";
cells[i].LowerUnitInfoLabel.text = "";
@@ -385,99 +385,98 @@ namespace Shardok {
Model.MyUncommittedUnits.Where(uv => uv.Location.Row == -1).ToList();
}
/// <summary>
/// Called when the ShardokGameModel is updated. This is invoked from UpdateAction,
/// which is called from ShardokGameModel.HandleUpdates, which runs on MainQueue.
/// No need to re-enqueue - we're already on the main thread.
/// </summary>
void ModelUpdated() {
if (Model == null) { return; }
SetHeroLabels();
SetModifiers();
UpdateReserves();
MainQueue.Q.Enqueue(() => {
if (Model == null) { return; }
SetHeroLabels();
SetModifiers();
UpdateReserves();
HandleEnemyStartingPositionOverlays();
HandleEnemyStartingPositionOverlays();
endTurnButton.interactable = false;
endTurnButton.interactable = false;
if (Model.GameStatus != null &&
(Model.GameStatus.State == GameStatus.Types.State.Victory)) {
turnStatusLabel.text = "Game Over!";
if (Model.GameStatus != null &&
(Model.GameStatus.State == GameStatus.Types.State.Victory)) {
turnStatusLabel.text = "Game Over!";
gameOverText.text = Model.GameStatus.Description;
gameOverCanvas.gameObject.SetActive(true);
gameOverText.text = Model.GameStatus.Description;
gameOverCanvas.gameObject.SetActive(true);
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Back to Eagle";
endTurnButton.interactable = true;
} else if (Model.GameStatus != null && Model.MyTurn) {
gameOverCanvas.gameObject.SetActive(false);
turnStatusLabel.text = "Your Turn";
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Back to Eagle";
endTurnButton.interactable = true;
} else if (Model.GameStatus != null && Model.MyTurn) {
gameOverCanvas.gameObject.SetActive(false);
turnStatusLabel.text = "Your Turn";
if (Model.InSetUp) {
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Commit";
if (Model.InSetUp) {
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Commit";
var unplacedUnitsWithLocations =
Model.MyUncommittedUnits
.Where(u => u.Location.Row >= 0 && u.Location.Column >= 0)
.ToList();
var unplacedUnitsWithLocations =
Model.MyUncommittedUnits
.Where(u => u.Location.Row >= 0 && u.Location.Column >= 0)
.ToList();
if (!Model.MyTurn) {
endTurnButton.interactable = false;
} else if (unplacedUnitsWithLocations.Count() < 1) {
endTurnButton.GetComponentInChildren<TMP_Text>().text = "No units placed";
endTurnButton.interactable = false;
} else if (unplacedUnitsWithLocations.Count() > 10) {
endTurnButton.GetComponentInChildren<TMP_Text>().text = "Too many units";
endTurnButton.interactable = false;
if (!Model.MyTurn) {
endTurnButton.interactable = false;
} else if (unplacedUnitsWithLocations.Count() < 1) {
endTurnButton.GetComponentInChildren<TMP_Text>().text =
"No units placed";
endTurnButton.interactable = false;
} else if (unplacedUnitsWithLocations.Count() > 10) {
endTurnButton.GetComponentInChildren<TMP_Text>().text =
"Too many units";
endTurnButton.interactable = false;
} else {
endTurnButton.interactable = true;
}
SetDisplayedCommandGroup(0);
} else {
endTurnButton.GetComponentInChildren<TMP_Text>().text = "End Turn";
}
if (Model.HasAvailableCommandWhere(
command => commandTypeUIManager.CommandGroupForType(command.Type) ==
CommandTypeUIManager.EndTurnCommandGroup)) {
endTurnButton.interactable = true;
}
SetDisplayedCommandGroup(0);
SelectAppropriateDefaultCommand();
} else {
endTurnButton.GetComponentInChildren<TMP_Text>().text = "End Turn";
turnStatusLabel.text = $"{Model.CurrentPlayerName}'s Turn";
}
if (Model.HasAvailableCommandWhere(
command => commandTypeUIManager.CommandGroupForType(command.Type) ==
CommandTypeUIManager.EndTurnCommandGroup)) {
endTurnButton.interactable = true;
locationNameText.text = Model.LocationName;
if (Model.History.Count > 0) {
string monthString = new DateTime(777, Model.Month, 1)
.ToString("MMMM", CultureInfo.InvariantCulture);
roundInfoText.text = $"{monthString} {Model.CurrentRound}";
Weather weather = Model.Weather;
if (weather != null) {
roundInfoText.text += ", " + ProtoExtensions.WeatherToString(weather);
}
}
SelectAppropriateDefaultCommand();
} else {
turnStatusLabel.text = $"{Model.CurrentPlayerName}'s Turn";
}
if (Model.History.Count == 0) {
turnHistoryButtonText.text = NoHistoryText;
} else if (Model.History.Count > _lastRetrievedHistoryCount) {
for (int i = _lastRetrievedHistoryCount; i < Model.History.Count; i++) {
var historyEntry = Model.History[i];
turnHistoryPanel.AddLine(GetActionResultDescription(historyEntry));
locationNameText.text = Model.LocationName;
if (Model.History.Count > 0) {
string monthString = new DateTime(777, Model.Month, 1)
.ToString("MMMM", CultureInfo.InvariantCulture);
roundInfoText.text = $"{monthString} {Model.CurrentRound}";
Weather weather = Model.Weather;
if (weather != null) {
roundInfoText.text += ", " + ProtoExtensions.WeatherToString(weather);
ActionType type = historyEntry.Type;
var thisSound = soundManager.SoundForType(type);
if (thisSound != null) { audioClipSource.PlayOneShot(thisSound, 1.0f); }
}
_lastRetrievedHistoryCount = Model.History.Count;
turnHistoryButtonText.text = GetActionResultDescription(Model.History.Last());
SetModifiers();
}
}
if (Model.History.Count == 0) {
turnHistoryButtonText.text = NoHistoryText;
} else if (Model.History.Count > _lastRetrievedHistoryCount) {
for (int i = _lastRetrievedHistoryCount; i < Model.History.Count; i++) {
var historyEntry = Model.History[i];
turnHistoryPanel.AddLine(GetActionResultDescription(historyEntry));
ActionType type = historyEntry.Type;
var thisSound = soundManager.SoundForType(type);
if (thisSound != null) { audioClipSource.PlayOneShot(thisSound, 1.0f); }
}
_lastRetrievedHistoryCount = Model.History.Count;
turnHistoryButtonText.text = GetActionResultDescription(Model.History.Last());
SetModifiers();
}
SetupArmiesTable();
SetupArmiesTable();
});
}
private void SetupArmiesTable() {
@@ -708,33 +707,34 @@ namespace Shardok {
}
void SetModifiers() {
// Called from ModelUpdated which runs on MainQueue - no need to re-enqueue
hexGrid.ClearCellModifierImages();
MainQueue.Q.Enqueue(() => {
hexGrid.ClearCellModifierImages();
for (byte row = 0; row < Model.Map.RowCount; row++) {
for (byte column = 0; column < Model.Map.ColumnCount; column++) {
Coords coords = new Coords();
coords.Row = row;
coords.Column = column;
for (byte row = 0; row < Model.Map.RowCount; row++) {
for (byte column = 0; column < Model.Map.ColumnCount; column++) {
Coords coords = new Coords();
coords.Row = row;
coords.Column = column;
var terrain = Model.Map.TerrainAt(coords);
int cellIndex = MapCoordsToGridIndex(coords);
int numberForCell = _randomNumberForCellIndex[cellIndex];
hexGrid.SetCellTerrainImage(
cellIndex,
_imageForTerrainTracker
.GetImageForTerrain(terrain, numberForCell, Model.Month));
if (terrain.Modifier?.Fire != null) {
hexGrid.SetCellModifierEffect(cellIndex, fireEffectPrefab);
} else {
hexGrid.SetCellModifierEffect(cellIndex, null);
}
var terrain = Model.Map.TerrainAt(coords);
int cellIndex = MapCoordsToGridIndex(coords);
int numberForCell = _randomNumberForCellIndex[cellIndex];
hexGrid.SetCellTerrainImage(
cellIndex,
_imageForTerrainTracker
.GetImageForTerrain(terrain, numberForCell, Model.Month));
if (terrain.Modifier?.Fire != null) {
hexGrid.SetCellModifierEffect(cellIndex, fireEffectPrefab);
} else {
hexGrid.SetCellModifierEffect(cellIndex, null);
}
if (terrain.Modifier?.Bridge != null) {
hexGrid.SetCellModifierImage(cellIndex, bridgeImage);
if (terrain.Modifier?.Bridge != null) {
hexGrid.SetCellModifierImage(cellIndex, bridgeImage);
}
}
}
}
});
}
void HandleButton() {
@@ -82,7 +82,7 @@ public class ShardokGameModel {
public int Month { get; private set; }
private List<PlayerTotals> PlayerTotals { get; set; }
public List<CommandDescriptor> AvailableCommands { get; private set; } = new();
public List<CommandDescriptor> AvailableCommands { get; private set; }
public Action UpdateAction { get; set; }
public List<PlayerWithHostility> players;
public String GetPlayerName(PlayerId playerId) {
@@ -217,8 +217,6 @@ public class ShardokGameModel {
? newCommands.CurrentCommand.ToList()
: newCommands.PreviewCommand.ToList();
}
// Trigger UI refresh after commands are updated so unit action indicators reflect new state
UpdateAction?.Invoke();
}
public bool HasTargetedCommand(Coords start, Coords target, List<CommandType> possibleTypes) {
@@ -138,9 +138,9 @@ namespace common {
// Use TMP's calculated preferred height - this is the actual text size
contentHeight = tmpText.preferredHeight;
// Sync Content RectTransform height with actual text size.
// Must both grow AND shrink to prevent blank space when scrolling to bottom.
if (Mathf.Abs(scrollRect.content.rect.height - contentHeight) > 1f) {
// Ensure Content RectTransform is tall enough to contain the text
// This is necessary for ScrollRect to scroll properly
if (scrollRect.content.rect.height < contentHeight) {
scrollRect.content.SetSizeWithCurrentAnchors(
RectTransform.Axis.Vertical,
contentHeight);
@@ -54,43 +54,18 @@ namespace common.GUIUtils {
Type = type,
Time = DateTime.UtcNow
});
// Try to display immediately if possible, otherwise queue for later
try {
if (MainQueue.Q != null) {
MainQueue.Q.Enqueue(ShowErrorPanel);
} else {
_pendingShow = true;
}
} catch {
// MainQueue not ready yet - will show in Update
_pendingShow = true;
}
}
}
private bool _pendingShow = false;
private void ShowErrorPanel() {
if (errorTextField != null && panel != null) {
errorTextField.text = AllMessageText;
panel.gameObject.SetActive(true);
}
}
// Register for log messages as early as possible
void Awake() { Application.logMessageReceivedThreaded += HandleLog; }
void Update() {
// Handle errors that occurred before MainQueue was ready
if (_pendingShow) {
_pendingShow = false;
ShowErrorPanel();
MainQueue.Q.Enqueue(() => {
errorTextField.text = AllMessageText;
panel.gameObject.SetActive(true);
});
}
}
// Use this for initialization
void Start() { panel.gameObject.SetActive(false); }
void Start() {
panel.gameObject.SetActive(false);
Application.logMessageReceivedThreaded += HandleLog;
}
public void DismissClicked() {
lock (_messages) {
@@ -6,16 +6,12 @@ using UnityEngine;
namespace common {
public sealed class Logger : IDisposable {
private readonly StreamWriter _sw;
private readonly object _lock = new object();
private static Dictionary<String, Logger> _loggers = new Dictionary<String, Logger>();
private static readonly object _loggersLock = new object();
public static Logger GetLogger(string name) {
lock (_loggersLock) {
if (!_loggers.ContainsKey(name)) { _loggers[name] = new Logger(name); }
return _loggers[name];
}
if (!_loggers.ContainsKey(name)) { _loggers[name] = new Logger(name); }
return _loggers[name];
}
private string CurrentTimeString() {
@@ -33,10 +29,8 @@ namespace common {
}
public void LogLine(string line) {
lock (_lock) {
_sw.WriteLine(CurrentTimeString() + " " + line);
_sw.Flush();
}
_sw.WriteLine(CurrentTimeString() + " " + line);
_sw.Flush();
}
public void Close() { _sw.Close(); }
@@ -1,6 +1,6 @@
{
"dependencies": {
"com.cysharp.yetanotherhttphandler": "https://github.com/Cysharp/YetAnotherHttpHandler.git?path=src/YetAnotherHttpHandler#1.11.4",
"com.cysharp.yetanotherhttphandler": "https://github.com/Cysharp/YetAnotherHttpHandler.git?path=src/YetAnotherHttpHandler#1.5.3",
"com.unity.2d.sprite": "1.0.0",
"com.unity.2d.tilemap": "1.0.0",
"com.unity.ai.navigation": "2.0.9",
@@ -1,11 +1,11 @@
{
"dependencies": {
"com.cysharp.yetanotherhttphandler": {
"version": "https://github.com/Cysharp/YetAnotherHttpHandler.git?path=src/YetAnotherHttpHandler#1.11.4",
"version": "https://github.com/Cysharp/YetAnotherHttpHandler.git?path=src/YetAnotherHttpHandler#1.5.3",
"depth": 0,
"source": "git",
"dependencies": {},
"hash": ""
"hash": "ceb2f24f343c93d66c8a784a283be65b9627efe1"
},
"com.unity.2d.sprite": {
"version": "1.0.0",
@@ -15,16 +15,5 @@ go_library(
go_binary(
name = "admin_server",
embed = [":admin_server_lib"],
pure = "on",
visibility = ["//visibility:public"],
)
# Linux x86_64 target for Docker deployment
go_binary(
name = "admin_server_linux_amd64",
embed = [":admin_server_lib"],
goarch = "amd64",
goos = "linux",
pure = "on",
visibility = ["//visibility:public"],
)
@@ -21,24 +21,14 @@ option java_outer_classname = "EagleInterface";
option objc_class_prefix = "E0G";
service ShardokInternalInterface {
// Server-side streaming for game updates - replaces polling via GetGameStatus
rpc SubscribeToGame(GameSubscriptionRequest) returns (stream GameStatusResponse) {}
rpc PostCommand(PostCommandRequest) returns (GameStatusResponse) {}
rpc PostPlacementCommands(PlacementCommandsRequest) returns (GameStatusResponse) {}
// Deprecated: Use SubscribeToGame for streaming updates instead
rpc GetGameStatus(GameStatusRequest) returns (GameStatusResponse) {}
rpc GetHexMap(HexMapRequest) returns (HexMapResponse) {}
rpc GetHexMapNames(HexMapNamesRequest) returns (HexMapNamesResponse) {}
}
message GameSubscriptionRequest {
string game_id = 1;
GameSetupInfo game_setup_info = 2;
}
message PostCommandRequest {
string game_id = 1;
int32 player_id = 2;
@@ -3,22 +3,6 @@ load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
load("@rules_proto//proto:defs.bzl", "proto_library")
load("@rules_scala//scala_proto:scala_proto.bzl", "scala_proto_library")
# Auth service for OAuth login
scala_proto_library(
name = "auth_scala_grpc",
visibility = [
"//src/main/scala/net/eagle0:__subpackages__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
deps = [":auth_grpc"],
)
proto_library(
name = "auth_grpc",
srcs = ["auth.proto"],
visibility = ["//visibility:public"],
)
scala_proto_library(
name = "eagle_scala_grpc",
visibility = [
@@ -258,7 +242,6 @@ go_proto_library(
compilers = ["@io_bazel_rules_go//proto:go_grpc"], # keep
importpath = "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/api/eagle", # keep
protos = [
":auth_grpc",
":available_command_proto",
":command_proto",
":eagle_grpc",
@@ -1,108 +0,0 @@
//
// Copyright 2025 Dan Crosby
//
syntax = "proto3";
package net.eagle0.eagle.api.auth;
option java_multiple_files = true;
option java_package = "net.eagle0.eagle.api.auth";
option java_outer_classname = "AuthProto";
option objc_class_prefix = "E0A";
// Authentication service for OAuth login
service Auth {
// Get OAuth URL to open in system browser
rpc GetOAuthUrl(GetOAuthUrlRequest) returns (GetOAuthUrlResponse) {}
// Exchange authorization code for JWT tokens
rpc ExchangeCode(ExchangeCodeRequest) returns (ExchangeCodeResponse) {}
// Set or update display name (requires valid JWT)
rpc SetDisplayName(SetDisplayNameRequest) returns (SetDisplayNameResponse) {}
// Refresh access token using refresh token
rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse) {}
// Get current user info (validates JWT)
rpc GetCurrentUser(GetCurrentUserRequest) returns (GetCurrentUserResponse) {}
// Logout - invalidates refresh token
rpc Logout(LogoutRequest) returns (LogoutResponse) {}
}
// OAuth provider enumeration
enum OAuthProvider {
OAUTH_PROVIDER_UNSPECIFIED = 0;
OAUTH_PROVIDER_DISCORD = 1;
OAUTH_PROVIDER_GOOGLE = 2;
}
// Request to initiate OAuth flow
message GetOAuthUrlRequest {
OAuthProvider provider = 1;
string redirect_uri = 2; // e.g., "eagle0://auth/callback"
}
message GetOAuthUrlResponse {
string auth_url = 1; // Full OAuth URL to open in system browser
string state = 2; // State parameter for CSRF protection
}
// Exchange authorization code for JWT
message ExchangeCodeRequest {
OAuthProvider provider = 1;
string authorization_code = 2;
string state = 3; // Must match state from GetOAuthUrlResponse
string redirect_uri = 4; // Must match exactly what was used in GetOAuthUrlRequest
}
message ExchangeCodeResponse {
string access_token = 1; // JWT for API access
string refresh_token = 2; // Long-lived token for getting new access tokens
int64 expires_at = 3; // Unix timestamp when access_token expires
UserInfo user = 4;
bool is_new_user = 5; // True if user needs to set display name
}
// User information returned from auth endpoints
message UserInfo {
string user_id = 1; // Internal Eagle0 user ID (UUID)
string display_name = 2; // Player-chosen display name (may be empty for new users)
string avatar_url = 3; // Profile picture URL from OAuth provider
OAuthProvider provider = 4;
}
// Set or update display name
message SetDisplayNameRequest {
string display_name = 1; // 3-20 characters, alphanumeric + underscore
}
message SetDisplayNameResponse {
bool success = 1;
string error_message = 2; // If not success: "Name taken", "Invalid characters", etc.
UserInfo user = 3;
}
// Refresh access token
message RefreshTokenRequest {
string refresh_token = 1;
}
message RefreshTokenResponse {
string access_token = 1;
int64 expires_at = 2;
}
// Get current user info
message GetCurrentUserRequest {}
message GetCurrentUserResponse {
UserInfo user = 1;
}
// Logout
message LogoutRequest {}
message LogoutResponse {}
@@ -284,12 +284,6 @@ message ProfessionGainedDetails {
Profession new_profession = 3;
}
message NewFactionHeadDetails {
int32 new_head_hero_id = 1;
int32 faction_id = 2;
int32 previous_head_hero_id = 3;
}
message NotificationDetails {
oneof sealed_value {
AllianceAcceptedDetails alliance_accepted_details = 30;
@@ -330,6 +324,5 @@ message NotificationDetails {
VassalExiledDetails vassal_exiled_details = 8;
WithdrewForTruceDetails withdrew_for_truce = 3;
ProfessionGainedDetails profession_gained_details = 35;
NewFactionHeadDetails new_faction_head_details = 39;
}
}
@@ -772,17 +772,6 @@ proto_library(
],
)
scala_proto_library(
name = "user_scala_proto",
deps = [":user_proto"],
)
proto_library(
name = "user_proto",
srcs = ["user.proto"],
deps = ["@com_google_protobuf//:timestamp_proto"],
)
go_proto_library(
name = "internal_go_proto",
importpath = "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/internal",
@@ -813,7 +802,6 @@ go_proto_library(
":shardok_results_proto",
":supplies_proto",
":unaffiliated_hero_proto",
":user_proto",
],
deps = [
"//src/main/protobuf/net/eagle0/common:common_go_proto",
@@ -53,6 +53,4 @@ message ChangedFaction {
.google.protobuf.Int32Value new_last_acted_province_id = 22;
bool clear_last_acted_province_id = 23;
.google.protobuf.StringValue new_name = 26;
}
@@ -72,7 +72,6 @@ message GeneratedTextRequestDetails {
PrisonerReleasedMessage prisoner_released_message = 32;
PrisonerExiledMessage prisoner_exiled_message = 33;
PrisonerReturnedMessage prisoner_returned_message = 34;
NewFactionHeadMessage new_faction_head_message = 35;
}
}
@@ -335,13 +334,3 @@ message PrisonerReturnedMessage {
int32 to_faction_id = 4;
int32 province_id = 5;
}
message NewFactionHeadMessage {
int32 new_head_hero_id = 1;
int32 faction_id = 2;
string previous_faction_name = 3;
// If present, the faction is being renamed to this
string new_faction_name = 4;
// The previous faction head who was executed
int32 previous_head_hero_id = 5;
}
@@ -58,8 +58,4 @@ message Hero {
repeated EventForHeroBackstory new_backstory_events = 29;
.net.eagle0.eagle.common.Gender pronoun_gender = 27;
// The name of the faction this hero would lead, if they led one.
// Populated for "great person" heroes from the heroes TSV; empty for others.
string led_faction_name = 30;
}
@@ -1,65 +0,0 @@
//
// Copyright 2025 Dan Crosby
//
syntax = "proto3";
package net.eagle0.eagle.internal.user;
import "google/protobuf/timestamp.proto";
option java_multiple_files = true;
option java_package = "net.eagle0.eagle.internal.user";
// Internal user record stored in users.pb
message User {
string user_id = 1; // UUID generated by Eagle
string display_name = 2; // Unique, player-chosen name
string display_name_lower = 3; // Lowercase version for uniqueness checks
// OAuth identities (a user can have multiple linked providers)
repeated OAuthIdentity oauth_identities = 4;
google.protobuf.Timestamp created_at = 5;
google.protobuf.Timestamp last_login_at = 6;
string avatar_url = 7;
// Admin flag - allows impersonating other users for debugging
// Set manually in the database, not via API
bool is_admin = 8;
}
// OAuth identity from a provider
message OAuthIdentity {
string provider = 1; // "discord" or "google"
string provider_user_id = 2; // ID from the OAuth provider
string email = 3; // Email from provider (for account recovery)
google.protobuf.Timestamp linked_at = 4;
}
// Container for all users (stored as single file)
message UserDatabase {
repeated User users = 1;
// Indexes for fast lookup (provider:provider_user_id -> user_id)
map<string, string> oauth_index = 2;
// display_name_lower -> user_id
map<string, string> display_name_index = 3;
}
// Refresh token record
message RefreshToken {
string token_id = 1; // UUID
string user_id = 2;
string token_hash = 3; // SHA-256 hash of actual token
google.protobuf.Timestamp created_at = 4;
google.protobuf.Timestamp expires_at = 5;
bool revoked = 6;
}
// Container for refresh tokens
message RefreshTokenDatabase {
repeated RefreshToken tokens = 1;
}
@@ -43,6 +43,4 @@ message FactionViewDiff {
repeated .net.eagle0.eagle.common.DiplomacyOffer removed_incoming_diplomacy_offers = 13;
.google.protobuf.Int32Value new_focus_province_id = 11;
.google.protobuf.StringValue new_name = 14;
}
@@ -4,7 +4,6 @@ filegroup(
name = "settings",
srcs = ["settings.tsv"],
visibility = [
"//ci:__pkg__",
"//src/main/scala/net/eagle0/eagle:__pkg__",
"//src/main/scala/net/eagle0/eagle/service:__pkg__",
],
@@ -14,7 +13,6 @@ filegroup(
name = "game_parameters",
srcs = ["game_parameters.json"],
visibility = [
"//ci:__pkg__",
"//src/main/scala/net/eagle0:__subpackages__",
"//src/test:__subpackages__",
],
@@ -24,7 +22,6 @@ filegroup(
name = "beasts",
srcs = ["beasts.tsv"],
visibility = [
"//ci:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util:__pkg__",
"//src/main/scala/net/eagle0/util:__pkg__",
"//src/test:__subpackages__",
@@ -35,7 +32,6 @@ filegroup(
name = "headshots",
srcs = ["headshots.tsv"],
visibility = [
"//ci:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util/hero_generator/image_paths:__pkg__",
"//src/main/scala/net/eagle0/eagle/library/util/hero_generator/random_hero_generator:__pkg__",
"//src/main/scala/net/eagle0/util:__pkg__",
@@ -50,7 +46,6 @@ filegroup(
"heroes.tsv",
],
visibility = [
"//ci:__pkg__",
"//src/main/scala/net/eagle0/eagle/service/new_game_creation:__pkg__",
"//src/main/scala/net/eagle0/util:__pkg__",
"//src/test:__subpackages__",
@@ -61,7 +56,6 @@ filegroup(
name = "province_map",
srcs = ["province_map.tsv"],
visibility = [
"//ci:__pkg__",
"//src/main/scala/net/eagle0:__subpackages__",
"//src/test:__subpackages__",
],
@@ -5,7 +5,6 @@ filegroup(
name = "settings",
srcs = ["settings.tsv"],
visibility = [
"//ci:__pkg__",
"//src/main/cpp/net/eagle0/shardok:__subpackages__",
"//src/test/cpp/net/eagle0/shardok:__subpackages__",
],
@@ -45,7 +44,6 @@ filegroup(
name = "battalion_types",
srcs = ["battalionTypes.tsv"],
visibility = [
"//ci:__pkg__",
"//src/main/cpp/net/eagle0/shardok:__subpackages__",
"//src/main/scala/net/eagle0/eagle/service:__pkg__",
"//src/main/scala/net/eagle0/util:__pkg__",
@@ -7,11 +7,7 @@ object ApiKeys {
private val apiKeyFilePath =
"src/main/scala/net/eagle0/common/llm_integration/api_keys.txt"
// Environment variable names
private val openAIEnvVar = "OPENAI_API_KEY"
private val anthropicEnvVar = "ANTHROPIC_API_KEY"
// File key names
// These must have valid values set in api_keys.txt
private val openAIKeyName = "openai_api_key"
private val anthropicKeyName = "anthropic_api_key"
@@ -27,31 +23,25 @@ object ApiKeys {
)
}.toMap
}.getOrElse {
Map.empty // Return empty map if file doesn't exist; we'll check env vars
throw new ApiKeyException(
"Failed to read api_keys.txt. Make sure you have copied api_keys_template.txt to api_keys.txt and set the keys."
)
}
private val dictionary = readDictionary()
private def getKey(key: String, envVar: String): String =
// First check environment variable
Option(System.getenv(envVar)).filter(_.nonEmpty) match {
case Some(value) => value
case None =>
// Fall back to file-based dictionary
dictionary.get(key) match {
case None =>
throw new ApiKeyException(
s"$key not found. Set $envVar environment variable or add to $apiKeyFilePath."
)
case Some(value) if value.startsWith("your_") =>
throw new ApiKeyException(
s"API key for $key is not set. Set $envVar environment variable or update $apiKeyFilePath."
)
case Some(value) => value
}
private def getKey(key: String): String =
dictionary.get(key) match {
case None =>
throw new ApiKeyException(s"$key not found")
case Some(value) if value.startsWith("your_") =>
throw new ApiKeyException(
s"API key for $key is not set. Please set it in $apiKeyFilePath."
)
case Some(value) => value
}
lazy val openAI: String = getKey(openAIKeyName, openAIEnvVar)
lazy val openAI: String = getKey(openAIKeyName)
lazy val anthropic: String = getKey(anthropicKeyName, anthropicEnvVar)
lazy val anthropic: String = getKey(anthropicKeyName)
}
+1 -8
View File
@@ -7,26 +7,19 @@ scala_binary(
visibility = ["//visibility:public"],
deps = [
":eagle_pkg",
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
"//src/main/protobuf/net/eagle0/eagle/api:eagle_scala_grpc",
"//src/main/protobuf/net/eagle0/eagle/internal:user_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/common:simple_timed_logger",
"//src/main/scala/net/eagle0/eagle/auth:jwt_service",
"//src/main/scala/net/eagle0/eagle/auth:oauth_service",
"//src/main/scala/net/eagle0/eagle/auth:user_service",
"//src/main/scala/net/eagle0/eagle/service",
"//src/main/scala/net/eagle0/eagle/service:auth_service",
"//src/main/scala/net/eagle0/eagle/service:authorization_interceptor",
"//src/main/scala/net/eagle0/eagle/service:custom_battle_manager",
"//src/main/scala/net/eagle0/eagle/service:exception_interceptor",
"//src/main/scala/net/eagle0/eagle/service:games_manager",
"//src/main/scala/net/eagle0/eagle/service:server_setup_helpers",
"//src/main/scala/net/eagle0/eagle/service/persistence:local_file_persister",
"//src/main/scala/net/eagle0/eagle/service/persistence:save_directory",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_update",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_update_receiver",
# "@maven//:com_thesamet_scalapb_scalapb_runtime_3",
],
)
+1 -30
View File
@@ -6,11 +6,8 @@ import scala.util.Random
import io.grpc.{Server, ServerBuilder}
import net.eagle0.common.{FunctionalRandom, SeededRandom, SimpleTimedLogger}
import net.eagle0.eagle.api.auth.auth.AuthGrpc
import net.eagle0.eagle.api.eagle.EagleGrpc
import net.eagle0.eagle.auth.{JwtService, JwtServiceImpl, OAuthServiceImpl, UserServiceImpl}
import net.eagle0.eagle.service.*
import net.eagle0.eagle.service.persistence.{LocalFilePersister, SaveDirectory}
object Main {
import ServerSetupHelpers.*
@@ -95,13 +92,6 @@ object Main {
): Server = {
val serverBuilder = ServerBuilder.forPort(grpcPort)
// Initialize auth services
val jwtService: Option[JwtService] = JwtServiceImpl.fromEnvironment()
val authPersister = LocalFilePersister(SaveDirectory.saveDirectory)
val userService = new UserServiceImpl(authPersister)
val oauthService = new OAuthServiceImpl()
// Add Eagle game service
serverBuilder.addService(
EagleGrpc
.bindService(
@@ -109,27 +99,8 @@ object Main {
ExecutionContext.global
)
)
// Add Auth service (only if JWT is configured)
jwtService.foreach { jwt =>
serverBuilder.addService(
AuthGrpc
.bindService(
AuthServiceImpl(oauthService, userService, jwt),
ExecutionContext.global
)
)
SimpleTimedLogger.printLogger.logLine("OAuth auth service enabled")
}
if jwtService.isEmpty then {
SimpleTimedLogger.printLogger.logLine(
"OAuth auth service disabled (no JWT keys configured). Using Basic Auth only."
)
}
serverBuilder.intercept(new ExceptionInterceptor)
serverBuilder.intercept(new AuthorizationInterceptor(jwtService))
serverBuilder.intercept(new AuthorizationInterceptor)
serverBuilder.build
}
@@ -195,7 +195,7 @@ case class AIClient(
AlmsCommandSelector
.chosenAlmsForSupportWithMinimumCommandForProvince(
actingFactionId = fid,
gameState = GameStateConverter.fromProto(gs),
gameState = gs,
availableCommands = acs,
minimumFood = 0,
provinceId = opac.provinceId
+6 -18
View File
@@ -74,8 +74,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:available_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:province_gold_surplus_calculator",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:trust_for_diplomacy",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -107,7 +105,7 @@ scala_library(
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
],
@@ -146,7 +144,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_battalion_utils",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:province_gold_surplus_calculator",
],
)
@@ -182,7 +180,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util:date_utils",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
"//src/main/scala/net/eagle0/eagle/library/util:incoming_army_utils",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:alms_command_selector",
@@ -190,15 +187,14 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:available_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:expand_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:food_consumption_utils",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:fulfill_quests_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:improve_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:organize_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:province_gold_surplus_calculator",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/food_consumption:legacy_food_consumption_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province:legacy_province_utils",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
],
)
@@ -215,7 +211,6 @@ scala_library(
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:legacy_province_distances",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
],
)
@@ -241,10 +236,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:available_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:ransom_offer_helpers",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/province",
],
)
@@ -257,21 +249,17 @@ scala_library(
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/common:more_option",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:loyalty_gain_from_feast",
"//src/main/scala/net/eagle0/eagle/library/settings:minimum_loyalty_for_swear_brotherhood",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:hero_gift_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:swear_brotherhood_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:sworn_brother_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/province",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
],
)
@@ -5,7 +5,7 @@ import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.LegacyProvinceDistances
import net.eagle0.eagle.library.util.ProvinceDistances
object FactionLeaderProvinceRanker {
private def factionLeaderProvinceOrdering(
@@ -23,7 +23,7 @@ object FactionLeaderProvinceRanker {
gameState: GameState,
fromProvinceId: ProvinceId
): Ordering[Province] = Ordering.by((p: Province) =>
LegacyProvinceDistances.distanceThroughFriendliesOption(
ProvinceDistances.distanceThroughFriendliesOption(
fromProvinceId,
p.id,
factionId,
@@ -8,7 +8,6 @@ import net.eagle0.eagle.library.settings.MinChanceForAIInvite
import net.eagle0.eagle.library.util.command_choice_helpers.{ProvinceGoldSurplusCalculator, TrustForDiplomacy}
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.flatSelectionForType
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.FactionId
object InvitationCommandSelector {
@@ -23,16 +22,16 @@ object InvitationCommandSelector {
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[DiplomacyAvailableCommand](acs = availableCommands) { diplomacyAvailableCommand =>
val nativeGameState = GameStateConverter.fromProto(gameState)
diplomacyAvailableCommand.options.collect { case io: InvitationOption => io }.filter { invitationOption =>
TrustForDiplomacy.meetsConditionsForInvitation(
actingFactionId = actingFactionId,
targetFactionId = invitationOption.targetFactionId,
gameState = nativeGameState
gameState = gameState
)
}.filter { invitationOption =>
ProvinceGoldSurplusCalculator.provinceGoldSurplus(
nativeGameState.provinces(diplomacyAvailableCommand.actingProvinceId)
diplomacyAvailableCommand.actingProvinceId,
gameState
) >= invitationOption.goldCost
}.map { invitationOption =>
InvitationOptionWithChance(
@@ -40,8 +39,7 @@ object InvitationCommandSelector {
acceptanceChance = ResolveDiplomacyCommandSelector.invitationAcceptanceChance(
actingFactionId,
invitationOption.targetFactionId,
nativeGameState.factions.values.toVector,
nativeGameState.provinces.values.toVector
gameState
)
)
}.maxByOption(_.acceptanceChance)
@@ -5,7 +5,7 @@ import net.eagle0.eagle.api.available_command.{MarchAvailableCommand, MarchComma
import net.eagle0.eagle.api.selected_command.MarchSelectedCommand
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.util.{CommandSelection, LegacyBattalionUtils, LegacyProvinceDistances}
import net.eagle0.eagle.library.util.{CommandSelection, LegacyBattalionUtils, ProvinceDistances}
import net.eagle0.eagle.library.util.command_choice_helpers.ProvinceGoldSurplusCalculator.provinceGoldSurplus
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
@@ -23,7 +23,7 @@ object MarchTowardProvinceCommandChooser {
.map(dest => (opmc, gs.provinces(dest.provinceId)))
.flatMap {
case (opmc, p) =>
LegacyProvinceDistances
ProvinceDistances
.distanceThroughFriendliesOption(
destinationProvinceId,
p.id,
@@ -19,6 +19,7 @@ import net.eagle0.eagle.library.util.command_choice_helpers.{
CommandChoiceHelpers,
CommandChooser,
ExpandCommandSelector,
FoodConsumptionUtils,
FulfillQuestsCommandSelector,
ImproveCommandSelector,
OrganizeCommandSelector,
@@ -28,12 +29,9 @@ import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSele
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChoiceHelpers.*
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooserImplicits.*
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.food_consumption.LegacyFoodConsumptionUtils
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.util.LegacyProvinceDistances
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.views.battalion_view.BattalionView
import net.eagle0.eagle.views.province_view.FullProvinceInfo
@@ -139,7 +137,7 @@ object MidGameAIClient {
) =>
val actingProvince = gameState.provinces(actingProvinceId)
val foodMonthsToHoldBack =
LegacyFoodConsumptionUtils.foodConsumptionMonthsToHold(
FoodConsumptionUtils.foodConsumptionMonthsToHold(
actingProvinceId,
gameState
)
@@ -271,7 +269,7 @@ object MidGameAIClient {
RandomState(
ImproveCommandSelector.chosenImproveCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands
),
functionalRandom
@@ -300,7 +298,7 @@ object MidGameAIClient {
RandomState(
ImproveCommandSelector.chosenImproveCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands
),
functionalRandom
@@ -347,18 +345,7 @@ object MidGameAIClient {
acs = availableCommands,
choosersAndReasons = Vector(
(
(
fid: FactionId,
gs: GameState,
acs: Vector[AvailableCommand],
fr: FunctionalRandom
) =>
ExpandCommandSelector.maybeChosenExpandCommand(
fid,
GameStateConverter.fromProto(gs),
acs,
fr
),
ExpandCommandSelector.maybeChosenExpandCommand,
"no hostile neighbors, expand"
),
(
@@ -375,7 +362,7 @@ object MidGameAIClient {
RandomState(
ImproveCommandSelector.chosenImproveCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands
),
functionalRandom
@@ -463,7 +450,7 @@ object MidGameAIClient {
RandomState(
ImproveCommandSelector.chosenImproveCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands
),
functionalRandom
@@ -518,7 +505,7 @@ object MidGameAIClient {
AttackCommandChooser.chosenAttackCommand(
actingFactionId,
GameStateConverter.fromProto(gs),
gs,
acs,
reconnedHeroCountFor,
reconnedBattalionsFor
@@ -538,13 +525,13 @@ object MidGameAIClient {
AlmsCommandSelector
.chosenAlmsForSupportCommand(
actingFactionId = actingFactionId,
gameState = GameStateConverter.fromProto(gameState),
gameState = gameState,
availableCommands = availableCommands
)
.orElse {
ImproveCommandSelector.chosenImproveCommand(
actingFactionId = actingFactionId,
gameState = GameStateConverter.fromProto(gameState),
gameState = gameState,
availableCommands = availableCommands
)
}
@@ -577,7 +564,7 @@ object MidGameAIClient {
RandomState(
AttackCommandChooser.chosenSupportAttackCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands
),
functionalRandom
@@ -627,7 +614,7 @@ object MidGameAIClient {
RandomState(
SeekMoreLeadersCommandChooser.maybeSeekMoreLeadersCommand(
actingFactionId,
GameStateConverter.fromProto(gameState),
gameState,
availableCommands
),
functionalRandom
@@ -803,7 +790,7 @@ object MidGameAIClient {
OrganizeCommandSelector
.chosenOrganizeCommandWithFoodSurplus(
actingFactionId = fid,
gameState = GameStateConverter.fromProto(gs),
gameState = gs,
availableCommands = acs,
extraFoodSurplus = foodSurplus(
gs,
@@ -905,7 +892,7 @@ object MidGameAIClient {
val furthestOrigin = candidateCommands.flatMap { opmc =>
// distance to the faction leader province we're closest to
factionLeaderProvincesNeedingHeroes.flatMap { flp =>
LegacyProvinceDistances
ProvinceDistances
.distanceThroughFriendliesOption(
p1 = flp.id,
p2 = opmc.originProvinceId,
@@ -4,8 +4,8 @@ import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.{AvailableCommand, MarchAvailableCommand, MarchCommandFromOneProvince}
import net.eagle0.eagle.internal.faction.Faction
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.util.{LegacyProvinceDistances, ProvinceDistances}
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.library.util.ProvinceDistances
import net.eagle0.eagle.library.util.ProvinceDistances.ProvinceAndDistance
object MoveLeaderToBetterProvinceCommandChooser {
@@ -53,7 +53,7 @@ object MoveLeaderToBetterProvinceCommandChooser {
mcfop.originProvinceId,
gs
).flatMap { desiredPid =>
LegacyProvinceDistances
ProvinceDistances
.closestProvinceThroughFriendliesOption(
desiredPid,
mcfop.availableDestinationProvinces.map(_.provinceId).toVector,
@@ -39,12 +39,10 @@ import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSele
randomSelectionForType,
selectionForType
}
import net.eagle0.eagle.library.util.faction_utils.{FactionUtils, LegacyFactionUtils}
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.FactionId
object ResolveDiplomacyCommandSelector {
@@ -142,20 +140,6 @@ object ResolveDiplomacyCommandSelector {
- LegacyFactionUtils.prestige(invitedFid, gs)
- MinimumPrestigeDifferenceToInvite.doubleValue).floor.toInt.max(0)
/** Protoless version */
def invitationAcceptanceChance(
originatingFid: FactionId,
invitedFid: FactionId,
factions: Vector[FactionT],
provinces: Vector[ProvinceT]
): Int = {
val originatingFaction = factions.find(_.id == originatingFid).get
val invitedFaction = factions.find(_.id == invitedFid).get
(FactionUtils.prestige(originatingFaction, provinces)
- FactionUtils.prestige(invitedFaction, provinces)
- MinimumPrestigeDifferenceToInvite.doubleValue).floor.toInt.max(0)
}
private def resolveTruceOfferSelectedCommand(
actingFactionId: FactionId,
gameState: GameState,
@@ -4,6 +4,9 @@ import net.eagle0.common.MoreOption
import net.eagle0.eagle.api.available_command.{AvailableCommand, MarchAvailableCommand}
import net.eagle0.eagle.api.selected_command.MarchSelectedCommand
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.hero.Hero
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.settings.{LoyaltyGainFromFeast, MinimumLoyaltyForSwearBrotherhood}
import net.eagle0.eagle.library.util.{CommandSelection, ProvinceDistances}
import net.eagle0.eagle.library.util.command_choice_helpers.{
@@ -12,30 +15,26 @@ import net.eagle0.eagle.library.util.command_choice_helpers.{
SwearBrotherhoodCommandSelector,
SwornBrotherChooser
}
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.hero.HeroUtils
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.FactionId
/** Protoless version of SeekMoreLeadersCommandChooser. */
object SeekMoreLeadersCommandChooser {
private def needsMoreLeaders(
actingFactionId: FactionId,
gameState: GameState
): Boolean =
gameState.factions(actingFactionId).leaderIds.size < SwornBrotherChooser
gameState.factions(actingFactionId).leaders.size < SwornBrotherChooser
.desiredNumberOfLeaders(
FactionUtils.provinces(actingFactionId, gameState.provinces.values.toVector).size
LegacyFactionUtils.provinceCount(actingFactionId, gameState)
)
case class Candidate(hero: HeroT, province: ProvinceT)
case class Candidate(hero: Hero, province: Province)
private def maybeMoveTowardDestination(
command: MarchAvailableCommand,
hero: HeroT,
startingProvince: ProvinceT,
factionHeadProvince: ProvinceT,
hero: Hero,
startingProvince: Province,
factionHeadProvince: Province,
gameState: GameState
): Option[CommandSelection] = {
val opmc = command.oneProvinceCommands
@@ -51,7 +50,7 @@ object SeekMoreLeadersCommandChooser {
factionHeadProvince.id,
p.id,
factionHeadProvince.rulingFactionId.get,
gameState.provinces
gameState
)
.map(distance => (opmc, p, distance))
}
@@ -84,24 +83,23 @@ object SeekMoreLeadersCommandChooser {
actingFactionId: FactionId,
gameState: GameState,
acs: Vector[AvailableCommand]
): Option[CommandSelection] = {
val provincesVector = gameState.provinces.values.toVector
FactionUtils
.provinces(actingFactionId, provincesVector)
): Option[CommandSelection] =
LegacyFactionUtils
.provinces(actingFactionId, gameState)
.find(
_.rulingFactionHeroIds
.contains(gameState.factions(actingFactionId).factionHeadId)
)
.flatMap { provinceWithFactionHead =>
val candidates =
FactionUtils
.provinces(actingFactionId, provincesVector)
LegacyFactionUtils
.provinces(actingFactionId, gameState)
.filter(_.rulingFactionHeroIds.length > 1)
.flatMap { province =>
SwornBrotherChooser
.bestChoice(
province.rulingFactionHeroIds.map(gameState.heroes),
gameState.factions(actingFactionId).leaderIds
gameState.factions(actingFactionId).leaders.toVector
)
.map(hero => Candidate(hero, province))
}
@@ -119,7 +117,7 @@ object SeekMoreLeadersCommandChooser {
}
marchableCandidates
.maxByOption(x => HeroUtils.power(x._2.hero))
.maxByOption(x => LegacyHeroUtils.power(x._2.hero))
.flatMap {
case (command, candidate) =>
maybeMoveTowardDestination(
@@ -131,7 +129,6 @@ object SeekMoreLeadersCommandChooser {
)
}
}
}
def maybeSeekMoreLeadersCommand(
actingFactionId: FactionId,
@@ -147,13 +144,14 @@ object SeekMoreLeadersCommandChooser {
// First try a hero that's already here
val desiredHero = SwornBrotherChooser.bestChoice(
leaderProvince.rulingFactionHeroIds.map(gameState.heroes),
faction.leaderIds
faction.leaders.toVector
)
desiredHero.flatMap { hero =>
SwearBrotherhoodCommandSelector
.chosenCommandForSpecificHero(
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = acs,
desiredHeroId = hero.id
)
@@ -163,6 +161,7 @@ object SeekMoreLeadersCommandChooser {
) {
CommandChoiceHelpers.chosenFeastCommandIfAvailable(
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = acs,
reason = s"want to make hero ${hero.id} a leader"
)
@@ -171,6 +170,7 @@ object SeekMoreLeadersCommandChooser {
.orElse {
HeroGiftCommandSelector.chosenCommandForSpecificHero(
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = acs,
heroId = hero.id,
reason = "want to make this hero a leader"
@@ -1,51 +0,0 @@
load("@rules_scala//scala:scala.bzl", "scala_library")
scala_library(
name = "jwt_service",
srcs = ["JwtService.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
deps = [
"@maven//:com_nimbusds_nimbus_jose_jwt",
],
)
scala_library(
name = "oauth_config",
srcs = ["OAuthConfig.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
)
scala_library(
name = "oauth_service",
srcs = ["OAuthService.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
deps = [
":oauth_config",
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
"@maven//:org_json4s_json4s_ast_3",
"@maven//:org_json4s_json4s_core_3",
"@maven//:org_json4s_json4s_native_3",
],
)
scala_library(
name = "user_service",
srcs = ["UserService.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:user_scala_proto",
"//src/main/scala/net/eagle0/eagle/service/persistence:persister",
],
)
@@ -1,182 +0,0 @@
package net.eagle0.eagle.auth
import java.security.MessageDigest
import java.time.Instant
import java.util.{Date, UUID}
import scala.jdk.CollectionConverters.*
import scala.util.Try
import com.nimbusds.jose.{JWSAlgorithm, JWSHeader}
import com.nimbusds.jose.crypto.{RSASSASigner, RSASSAVerifier}
import com.nimbusds.jose.jwk.gen.RSAKeyGenerator
import com.nimbusds.jose.jwk.RSAKey
import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT}
/** JWT claims extracted from a validated token */
case class JwtClaims(
userId: String,
displayName: String,
isAdmin: Boolean,
issuedAt: Instant,
expiresAt: Instant
)
/** Refresh token with metadata */
case class RefreshTokenInfo(
tokenId: String,
userId: String,
tokenHash: String,
expiresAt: Instant
)
/** Service for creating and validating JWT tokens */
trait JwtService {
/** Create an access token for a user */
def createAccessToken(userId: String, displayName: String, isAdmin: Boolean): String
/** Create a refresh token (returns token string and info for storage) */
def createRefreshToken(userId: String): (String, RefreshTokenInfo)
/** Validate an access token and extract claims */
def validateAccessToken(token: String): Option[JwtClaims]
/** Hash a refresh token for storage */
def hashRefreshToken(token: String): String
}
class JwtServiceImpl(rsaKey: RSAKey) extends JwtService {
private val accessTokenDuration = java.time.Duration.ofDays(7)
private val refreshTokenDuration = java.time.Duration.ofDays(30)
private val issuer = "eagle0"
private val signer = new RSASSASigner(rsaKey)
private val verifier = new RSASSAVerifier(rsaKey.toPublicJWK)
override def createAccessToken(
userId: String,
displayName: String,
isAdmin: Boolean
): String = {
val now = Instant.now()
val expiresAt = now.plus(accessTokenDuration)
val claimsBuilder = new JWTClaimsSet.Builder()
.subject(userId)
.issuer(issuer)
.issueTime(Date.from(now))
.expirationTime(Date.from(expiresAt))
.claim("name", displayName)
// Only include admin claim if true to keep tokens smaller
if isAdmin then {
claimsBuilder.claim("admin", true)
}
val signedJWT = new SignedJWT(
new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID).build(),
claimsBuilder.build()
)
signedJWT.sign(signer)
signedJWT.serialize()
}
override def createRefreshToken(userId: String): (String, RefreshTokenInfo) = {
val tokenId = UUID.randomUUID().toString
val tokenSecret = UUID.randomUUID().toString
val token = s"$tokenId.$tokenSecret"
val tokenHash = hashRefreshToken(token)
val expiresAt = Instant.now().plus(refreshTokenDuration)
val info = RefreshTokenInfo(
tokenId = tokenId,
userId = userId,
tokenHash = tokenHash,
expiresAt = expiresAt
)
(token, info)
}
override def validateAccessToken(token: String): Option[JwtClaims] =
Try {
val signedJWT = SignedJWT.parse(token)
// Verify signature
if !signedJWT.verify(verifier) then {
return None
}
val claims = signedJWT.getJWTClaimsSet
// Check issuer
if claims.getIssuer != issuer then {
return None
}
// Check expiration
val expirationTime = claims.getExpirationTime
if expirationTime == null || expirationTime.before(new Date()) then {
return None
}
val userId = claims.getSubject
val displayName = Option(claims.getStringClaim("name")).getOrElse("")
val isAdmin = Option(claims.getBooleanClaim("admin")).map(_.booleanValue()).getOrElse(false)
val issuedAt = claims.getIssueTime.toInstant
val expiresAt = expirationTime.toInstant
Some(
JwtClaims(
userId = userId,
displayName = displayName,
isAdmin = isAdmin,
issuedAt = issuedAt,
expiresAt = expiresAt
)
)
}.toOption.flatten
override def hashRefreshToken(token: String): String = {
val digest = MessageDigest.getInstance("SHA-256")
val hashBytes = digest.digest(token.getBytes("UTF-8"))
hashBytes.map("%02x".format(_)).mkString
}
}
object JwtServiceImpl {
/** Generate a new RSA key pair for JWT signing */
def generateKey(): RSAKey =
new RSAKeyGenerator(2048)
.keyID(UUID.randomUUID().toString)
.generate()
/** Load RSA key from JSON string (JWK format) */
def loadKey(json: String): RSAKey =
RSAKey.parse(json)
/** Create a JwtService with a new generated key (for testing) */
def withGeneratedKey(): JwtServiceImpl =
new JwtServiceImpl(generateKey())
/** Create a JwtService from a JWK JSON string */
def fromKeyJson(json: String): JwtServiceImpl =
new JwtServiceImpl(loadKey(json))
/**
* Create a JwtService from environment variables.
*
* Looks for JWT_PRIVATE_KEY environment variable containing RSA key in JWK format. If not found, returns None (auth
* will fall back to Basic Auth only).
*/
def fromEnvironment(): Option[JwtServiceImpl] =
sys.env.get("JWT_PRIVATE_KEY").flatMap { keyJson =>
Try {
fromKeyJson(keyJson)
}.toOption
}
}
@@ -1,41 +0,0 @@
package net.eagle0.eagle.auth
/** Configuration for an OAuth provider */
case class OAuthProviderConfig(
clientId: String,
clientSecret: String,
authorizationEndpoint: String,
tokenEndpoint: String,
userInfoEndpoint: String,
scopes: Seq[String]
)
/** OAuth configuration loaded from environment variables */
object OAuthConfig {
def discord: OAuthProviderConfig = OAuthProviderConfig(
clientId = sys.env.getOrElse("DISCORD_CLIENT_ID", ""),
clientSecret = sys.env.getOrElse("DISCORD_CLIENT_SECRET", ""),
authorizationEndpoint = "https://discord.com/api/oauth2/authorize",
tokenEndpoint = "https://discord.com/api/oauth2/token",
userInfoEndpoint = "https://discord.com/api/users/@me",
scopes = Seq("identify", "email")
)
def google: OAuthProviderConfig = OAuthProviderConfig(
clientId = sys.env.getOrElse("GOOGLE_CLIENT_ID", ""),
clientSecret = sys.env.getOrElse("GOOGLE_CLIENT_SECRET", ""),
authorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth",
tokenEndpoint = "https://oauth2.googleapis.com/token",
userInfoEndpoint = "https://www.googleapis.com/oauth2/v2/userinfo",
scopes = Seq("openid", "email", "profile")
)
/** Check if Discord OAuth is configured */
def isDiscordConfigured: Boolean =
discord.clientId.nonEmpty && discord.clientSecret.nonEmpty
/** Check if Google OAuth is configured */
def isGoogleConfigured: Boolean =
google.clientId.nonEmpty && google.clientSecret.nonEmpty
}
@@ -1,213 +0,0 @@
// Copyright 2025 Dan Crosby
package net.eagle0.eagle.auth
import java.net.{URI, URLEncoder}
import java.net.http.{HttpClient, HttpRequest, HttpResponse}
import java.nio.charset.StandardCharsets
import java.time.Duration
import java.util.UUID
import scala.collection.concurrent.TrieMap
import scala.util.Try
import net.eagle0.eagle.api.auth.auth.OAuthProvider
import org.json4s.{DefaultFormats, JObject, JString}
import org.json4s.jvalue2extractable
import org.json4s.jvalue2monadic
import org.json4s.native.Json
/** User info from OAuth provider */
case class ProviderUserInfo(
id: String,
email: String,
avatarUrl: String,
username: String
)
/** Service for OAuth code exchange */
trait OAuthService {
/** Generate OAuth authorization URL */
def getAuthUrl(provider: OAuthProvider, redirectUri: String): (String, String) // (url, state)
/** Exchange authorization code for user info */
def exchangeCode(
provider: OAuthProvider,
code: String,
state: String,
redirectUri: String
): Either[String, ProviderUserInfo]
}
class OAuthServiceImpl extends OAuthService {
private implicit val formats: DefaultFormats.type = DefaultFormats
private val json = new Json(formats)
private val httpClient = HttpClient
.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build()
// State parameter storage with timestamp (for CSRF protection)
private val stateStore = TrieMap[String, Long]()
// State expiration (10 minutes)
private val stateExpirationMs = 600000L
override def getAuthUrl(provider: OAuthProvider, redirectUri: String): (String, String) = {
val config = getConfig(provider)
val state = UUID.randomUUID().toString
stateStore.put(state, System.currentTimeMillis())
// Clean old states
val cutoff = System.currentTimeMillis() - stateExpirationMs
stateStore.filterInPlace((_, timestamp) => timestamp > cutoff)
val params = Map(
"client_id" -> config.clientId,
"redirect_uri" -> redirectUri,
"response_type" -> "code",
"scope" -> config.scopes.mkString(" "),
"state" -> state
)
val queryString = params.map {
case (k, v) =>
s"$k=${URLEncoder.encode(v, StandardCharsets.UTF_8)}"
}
.mkString("&")
val url = s"${config.authorizationEndpoint}?$queryString"
(url, state)
}
override def exchangeCode(
provider: OAuthProvider,
code: String,
state: String,
redirectUri: String
): Either[String, ProviderUserInfo] = {
// Verify state
stateStore.remove(state) match {
case Some(timestamp) if System.currentTimeMillis() - timestamp < stateExpirationMs =>
// Valid state
case _ =>
return Left("Invalid or expired state parameter")
}
val config = getConfig(provider)
// Exchange code for access token
val tokenResult = exchangeCodeForToken(config, code, redirectUri)
tokenResult match {
case Left(error) => Left(error)
case Right(accessToken) =>
// Fetch user info
fetchUserInfo(provider, config, accessToken)
}
}
private def getConfig(provider: OAuthProvider): OAuthProviderConfig =
provider match {
case OAuthProvider.OAUTH_PROVIDER_DISCORD => OAuthConfig.discord
case OAuthProvider.OAUTH_PROVIDER_GOOGLE => OAuthConfig.google
case _ => throw new IllegalArgumentException(s"Unsupported provider: $provider")
}
private def exchangeCodeForToken(
config: OAuthProviderConfig,
code: String,
redirectUri: String
): Either[String, String] =
Try {
val formData = Map(
"client_id" -> config.clientId,
"client_secret" -> config.clientSecret,
"grant_type" -> "authorization_code",
"code" -> code,
"redirect_uri" -> redirectUri
)
val formBody = formData.map {
case (k, v) =>
s"${URLEncoder.encode(k, StandardCharsets.UTF_8)}=${URLEncoder.encode(v, StandardCharsets.UTF_8)}"
}
.mkString("&")
val request = HttpRequest
.newBuilder()
.uri(URI.create(config.tokenEndpoint))
.timeout(Duration.ofSeconds(10))
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(formBody))
.build()
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
val body = response.body()
if response.statusCode() < 200 || response.statusCode() >= 300 then {
return Left(s"Token exchange failed: ${response.statusCode()} - $body")
}
val parsed = json.parse(body)
val accessToken = (parsed \ "access_token").extract[String]
Right(accessToken)
}.toEither.left.map(e => s"Token exchange error: ${e.getMessage}").flatten
private def fetchUserInfo(
provider: OAuthProvider,
config: OAuthProviderConfig,
accessToken: String
): Either[String, ProviderUserInfo] =
Try {
val request = HttpRequest
.newBuilder()
.uri(URI.create(config.userInfoEndpoint))
.timeout(Duration.ofSeconds(10))
.header("Authorization", s"Bearer $accessToken")
.header("Accept", "application/json")
.GET()
.build()
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
val body = response.body()
if response.statusCode() < 200 || response.statusCode() >= 300 then {
return Left(s"User info fetch failed: ${response.statusCode()} - $body")
}
val parsed = json.parse(body)
parseUserInfo(provider, parsed)
}.toEither.left.map(e => s"User info fetch error: ${e.getMessage}").flatten
private def parseUserInfo(
provider: OAuthProvider,
data: org.json4s.JValue
): Either[String, ProviderUserInfo] =
Try {
provider match {
case OAuthProvider.OAUTH_PROVIDER_DISCORD =>
val id = (data \ "id").extract[String]
val username = (data \ "username").extract[String]
val email = (data \ "email").extractOpt[String].getOrElse("")
val avatarHash = (data \ "avatar").extractOpt[String]
val avatarUrl = avatarHash
.map(hash => s"https://cdn.discordapp.com/avatars/$id/$hash.png")
.getOrElse("")
ProviderUserInfo(id, email, avatarUrl, username)
case OAuthProvider.OAUTH_PROVIDER_GOOGLE =>
val id = (data \ "id").extract[String]
val email = (data \ "email").extractOpt[String].getOrElse("")
val name = (data \ "name").extractOpt[String].getOrElse("")
val avatarUrl = (data \ "picture").extractOpt[String].getOrElse("")
ProviderUserInfo(id, email, avatarUrl, name)
case _ =>
throw new IllegalArgumentException(s"Unsupported provider: $provider")
}
}.toEither.left.map(e => s"Failed to parse user info: ${e.getMessage}")
}
@@ -1,176 +0,0 @@
// Copyright 2025 Dan Crosby
package net.eagle0.eagle.auth
import java.time.Instant
import java.util.UUID
import scala.util.{Success, Using}
import com.google.protobuf.timestamp.Timestamp
import net.eagle0.eagle.internal.user.user.{OAuthIdentity, User, UserDatabase}
import net.eagle0.eagle.service.persistence.Persister
/** Service for managing user records */
trait UserService {
/** Find or create a user from OAuth provider info */
def findOrCreateUser(
provider: String,
providerUserId: String,
email: String,
avatarUrl: String
): User
/** Find user by internal user ID */
def findByUserId(userId: String): Option[User]
/** Find user by display name (case-insensitive) */
def findByDisplayName(displayName: String): Option[User]
/** Set or update display name */
def setDisplayName(userId: String, displayName: String): Either[String, User]
/** Check if display name is available */
def isDisplayNameAvailable(displayName: String): Boolean
}
class UserServiceImpl(persister: Persister) extends UserService {
private val usersPath = "auth/users.pb"
// Thread-safe access to user database
private var database: UserDatabase = loadDatabase()
private val lock = new Object
private def loadDatabase(): UserDatabase =
persister.retrieveAsStream(usersPath) match {
case Success(stream) =>
Using(stream) { s =>
UserDatabase.parseFrom(s)
}.getOrElse(UserDatabase.defaultInstance)
case _ => UserDatabase.defaultInstance
}
private def saveDatabase(): Unit = {
persister.save(usersPath, database.toByteArray)
()
}
private def nowTimestamp: Timestamp = {
val now = Instant.now()
Timestamp(now.getEpochSecond, now.getNano)
}
override def findOrCreateUser(
provider: String,
providerUserId: String,
email: String,
avatarUrl: String
): User = lock.synchronized {
val oauthKey = s"$provider:$providerUserId"
database.oauthIndex.get(oauthKey) match {
case Some(existingUserId) =>
// Update last login, avatar
val user = findByUserIdInternal(existingUserId).get
val updated = user.copy(
lastLoginAt = Some(nowTimestamp),
avatarUrl = avatarUrl
)
updateUser(updated)
updated
case None =>
// Create new user
val userId = UUID.randomUUID().toString
val now = nowTimestamp
val identity = OAuthIdentity(
provider = provider,
providerUserId = providerUserId,
email = email,
linkedAt = Some(now)
)
val user = User(
userId = userId,
displayName = "",
displayNameLower = "",
oauthIdentities = Seq(identity),
createdAt = Some(now),
lastLoginAt = Some(now),
avatarUrl = avatarUrl,
isAdmin = false
)
database = database.copy(
users = database.users :+ user,
oauthIndex = database.oauthIndex + (oauthKey -> userId)
)
saveDatabase()
user
}
}
override def findByUserId(userId: String): Option[User] = lock.synchronized {
findByUserIdInternal(userId)
}
private def findByUserIdInternal(userId: String): Option[User] =
database.users.find(_.userId == userId)
override def findByDisplayName(displayName: String): Option[User] = lock.synchronized {
val lowerName = displayName.toLowerCase
database.displayNameIndex.get(lowerName).flatMap(findByUserIdInternal)
}
override def setDisplayName(userId: String, displayName: String): Either[String, User] =
lock.synchronized {
// Validate: 3-20 chars, alphanumeric + underscore
val validPattern = "^[a-zA-Z0-9_]{3,20}$".r
if !validPattern.matches(displayName) then {
return Left("Display name must be 3-20 characters, alphanumeric and underscore only")
}
val lowerName = displayName.toLowerCase
// Check uniqueness
database.displayNameIndex.get(lowerName) match {
case Some(existingId) if existingId != userId =>
Left("Display name already taken")
case _ =>
findByUserIdInternal(userId) match {
case Some(user) =>
// Remove old name from index if exists
val newIndex = user.displayNameLower match {
case "" => database.displayNameIndex
case old => database.displayNameIndex - old
}
val updated = user.copy(
displayName = displayName,
displayNameLower = lowerName
)
database = database.copy(
users = database.users.filterNot(_.userId == userId) :+ updated,
displayNameIndex = newIndex + (lowerName -> userId)
)
saveDatabase()
Right(updated)
case None =>
Left("User not found")
}
}
}
override def isDisplayNameAvailable(displayName: String): Boolean = lock.synchronized {
!database.displayNameIndex.contains(displayName.toLowerCase)
}
private def updateUser(user: User): Unit = {
database = database.copy(
users = database.users.filterNot(_.userId == user.userId) :+ user
)
saveDatabase()
}
}
@@ -309,16 +309,14 @@ case class EngineImpl(
initialState = this.currentState,
actionResultApplier = ActionResultApplierImpl(Some(ScalaRuntimeValidator)),
functionalRandom = SeededRandom(this.currentState.randomSeed)
).withTCommandAndLastCommand(
commandGen = gs =>
commandFactory.makeTCommand(
actingFactionId = factionId,
gameState = gs,
availableCommand = availableCommand,
selectedCommand = selectedCommand
),
selectedCommand = selectedCommand
).withProtolessSequentialResultsAction(gs => HeroBackstoryUpdateActionGenerator(gs))
).withTCommand { gs =>
commandFactory.makeTCommand(
actingFactionId = factionId,
gameState = gs,
availableCommand = availableCommand,
selectedCommand = selectedCommand
)
}.withProtolessSequentialResultsAction(gs => HeroBackstoryUpdateActionGenerator(gs))
// Validate that the first result has an acting faction set (required for player commands)
val firstResult = sequencer.actionResults.newValue.headOption
@@ -9,6 +9,8 @@ case class ActionResultWithResultingState(
)
trait ActionResultApplier {
def xpForStatBump(stat: Int): Int
def applyActionResults(
startingState: GameState,
results: Iterable[ActionResultT]
@@ -56,6 +56,8 @@ class ActionResultApplierImpl(validator: Option[ScalaValidator]) extends ActionR
def validate(province: ProvinceT, roundPhase: RoundPhase): ProvinceT =
validator.fold(province)(_.validate(province, roundPhase))
override def xpForStatBump(stat: Int): Int = GameStateExtensions.xpForStatBump(stat)
override def applyActionResults(
startingState: GameState,
results: Iterable[ActionResultT]
@@ -112,7 +114,6 @@ class ActionResultApplierImpl(validator: Option[ScalaValidator]) extends ActionR
// Apply all remaining entity changes using extension methods
val finalState = stateAfterChangedBattalions
.applyProvinceActed(result.provinceIdActed)
.applyLastCommand(result.provinceId, result.lastCommandTypeForActingProvince)
.applyNewBattalions(result.newBattalions, result.provinceId)
.applyDestroyedBattalionIds(result.destroyedBattalionIds)
.applyNewHeroes(result.newHeroes)
@@ -4,6 +4,8 @@ import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.actions.impl.common.ActionWithResultingState
trait ActionResultProtoApplier {
def xpForStatBump(stat: Int): Int
def applyActionResults(
startingState: GameState,
results: Iterable[ActionResult]
@@ -47,6 +47,7 @@ import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.internal.run_status.RunStatus
import net.eagle0.eagle.internal.shardok_battle.ShardokBattle
import net.eagle0.eagle.library.actions.impl.common.ActionWithResultingState
import net.eagle0.eagle.library.settings.{ExtraXpForStatBumpOver100, XpForStatBump}
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.library.util.validations.Validator
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
@@ -56,6 +57,10 @@ import net.eagle0.eagle.library.EagleInternalException
class ActionResultProtoApplierImpl(validator: Validator) extends ActionResultProtoApplier {
import validator.validate
override def xpForStatBump(stat: Int): Int =
if stat <= 99 then XpForStatBump.intValue
else XpForStatBump.intValue + ExtraXpForStatBumpOver100.intValue * (stat - 99)
private val trustMax: Int = 100
implicit class _GS(gameState: GameState) {
@@ -9,6 +9,8 @@ case class ActionResultTWithResultingState(
)
trait ActionResultTApplier {
def xpForStatBump(stat: Int): Int
def applyActionResults(
startingState: GameState,
results: Iterable[ActionResultT]
@@ -6,7 +6,6 @@ import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
object ActionResultTApplierImpl {
/** Creates an ActionResultTApplierImpl with no validation (for tests) */
def apply(): ActionResultTApplierImpl =
new ActionResultTApplierImpl(new ActionResultApplierImpl(None))
@@ -19,6 +18,8 @@ object ActionResultTApplierImpl {
}
class ActionResultTApplierImpl(baseApplier: ActionResultApplier) extends ActionResultTApplier {
override def xpForStatBump(stat: Int): Int = baseApplier.xpForStatBump(stat)
override def applyActionResults(
startingState: GameState,
results: Iterable[ActionResultT]
@@ -54,7 +54,6 @@ scala_library(
deps = [
":province_update_helpers",
":province_update_helpers2",
"//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:eagle_internal_exception",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
@@ -107,6 +106,7 @@ scala_library(
srcs = ["GameStateFactionExtensions.scala"],
visibility = ["//visibility:private"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/views:province_view_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_faction_trait",
@@ -114,7 +114,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/faction/concrete",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/view/province:province_view",
],
)
@@ -155,7 +154,6 @@ scala_library(
":game_state_hero_extensions",
":game_state_misc_extensions",
":game_state_province_extensions",
"//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/model/action_result:changed_battalion_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_faction_trait",
@@ -171,7 +169,6 @@ scala_library(
":game_state_hero_extensions",
":game_state_misc_extensions",
":game_state_province_extensions",
"//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:extra_xp_for_stat_bump_over100",
"//src/main/scala/net/eagle0/eagle/library/settings:xp_for_stat_bump",
@@ -242,6 +239,8 @@ scala_library(
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/settings:extra_xp_for_stat_bump_over100",
"//src/main/scala/net/eagle0/eagle/library/settings:xp_for_stat_bump",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
"//src/main/scala/net/eagle0/eagle/library/util:province_event_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:legacy_hero_utils",
@@ -9,7 +9,7 @@ import net.eagle0.eagle.model.state.faction.FactionRelationship
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.faction.FactionT.OutgoingOfferRound
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.view.province.ProvinceView
import net.eagle0.eagle.views.province_view.ProvinceView
object GameStateFactionExtensions {
@@ -108,7 +108,6 @@ object GameStateFactionExtensions {
)
val updatedFaction = existingFaction.copy(
name = cf.newName.getOrElse(existingFaction.name),
factionHeadId = cf.newFactionHeadHeroId.getOrElse(existingFaction.factionHeadId),
leaderIds = existingFaction.leaderIds.diff(cf.removedLeaderHeroIds) ++ cf.newLeaderHeroIds,
prestigeModifiers =
@@ -1,7 +1,6 @@
package net.eagle0.eagle.library.actions.applier
import net.eagle0.eagle.*
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedProvinceC
@@ -76,22 +75,5 @@ object GameStateProvinceExtensions {
}
.getOrElse(gameState)
}
def applyLastCommand(
provinceId: Option[ProvinceId],
lastCommand: Option[SelectedCommand]
): GameState =
provinceId
.filter(_ => lastCommand.exists(!_.isEmpty))
.map { pid =>
val province = gameState.provinces(pid) match {
case p: ProvinceC => p
case p => throw new EagleInternalException(s"Unknown ProvinceT type: ${p.getClass}")
}
gameState.copy(
provinces = gameState.provinces.updated(pid, province.copy(lastCommand = lastCommand))
)
}
.getOrElse(gameState)
}
}
@@ -7,14 +7,9 @@ import net.eagle0.eagle.internal.army.Attacking
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.settings.{MaxCombatUnitCountPerSide, MinVigorForDefend}
import net.eagle0.eagle.library.util.battalion_suitability.BattalionSuitability
import net.eagle0.eagle.library.util.{BattalionSuitability, LegacyBattalionUtils}
import net.eagle0.eagle.library.util.command_choice_helpers.CombatUnitSelector
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.LegacyBattalionUtils
import net.eagle0.eagle.model.proto_converters.hero.HeroConverter
import net.eagle0.eagle.model.proto_converters.BattalionConverter
import net.eagle0.eagle.model.proto_converters.BattalionTypeConverter
import net.eagle0.eagle.model.proto_converters.SuitableBattalionsConverter
object AvailableDefendCommandsFactory extends AvailableCommandsFactoryForType {
private def sortedFleeProvinces(
@@ -76,9 +71,9 @@ object AvailableDefendCommandsFactory extends AvailableCommandsFactoryForType {
val recommendedUnits = CombatUnitSelector.selectedCombatBattalions(
desiredCount = availableHeroes.size.min(MaxCombatUnitCountPerSide.intValue),
heroes = availableHeroes.map(HeroConverter.fromProto).toVector,
battalions = availableBattalions.map(BattalionConverter.fromProto).toVector,
battalionTypes = gameState.battalionTypes.map(BattalionTypeConverter.fromProto).toVector
heroes = availableHeroes.toVector,
battalions = availableBattalions.toVector,
battalionTypes = gameState.battalionTypes.toVector
)
val hostileUnits = hostileArmies
.flatMap(_.armies)
@@ -91,12 +86,10 @@ object AvailableDefendCommandsFactory extends AvailableCommandsFactoryForType {
.toVector
.map(_.id),
actingProvinceId = province.id,
suitableBattalionsForHeroes = SuitableBattalionsConverter.toProtoMap(
BattalionSuitability.suitableBattalionsForHeroes(
hs = availableHeroes.map(HeroConverter.fromProto).toVector,
bs = availableBattalions.map(BattalionConverter.fromProto).toVector,
bts = gameState.battalionTypes.map(BattalionTypeConverter.fromProto).toVector
)
suitableBattalionsForHeroes = BattalionSuitability.suitableBattalionsForHeroes(
hs = availableHeroes.toVector,
bs = availableBattalions.toVector,
bts = gameState.battalionTypes.toVector
),
availableBattalions = availableBattalionsWithCosts,
availableFleeProvinceIds = sortedFleeProvinces(from = province, gs = gameState),
@@ -11,15 +11,10 @@ import net.eagle0.eagle.api.command.util.battalion_with_food_cost.BattalionWithF
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.internal.province.Province
import net.eagle0.eagle.library.settings.{MaxCombatUnitCountPerSide, MinVigorForMarch}
import net.eagle0.eagle.library.util.{LegacyBattalionUtils, ShardokMapInfo}
import net.eagle0.eagle.library.util.battalion_suitability.BattalionSuitability
import net.eagle0.eagle.library.util.{BattalionSuitability, LegacyBattalionUtils, ShardokMapInfo}
import net.eagle0.eagle.library.util.command_choice_helpers.{CombatUnitSelector, MarchSuppliesHelpers}
import net.eagle0.eagle.library.util.hero.LegacyHeroUtils
import net.eagle0.eagle.library.util.province.LegacyProvinceUtils
import net.eagle0.eagle.model.proto_converters.hero.HeroConverter
import net.eagle0.eagle.model.proto_converters.BattalionConverter
import net.eagle0.eagle.model.proto_converters.BattalionTypeConverter
import net.eagle0.eagle.model.proto_converters.SuitableBattalionsConverter
object AvailableMarchCommandFactory extends AvailableCommandsFactoryForType {
@@ -72,14 +67,14 @@ object AvailableMarchCommandFactory extends AvailableCommandsFactoryForType {
Option.when(recommendedSendCount > 0) {
val units = CombatUnitSelector.selectedCombatBattalions(
desiredCount = recommendedSendCount,
heroes = availableHeroes.map(HeroConverter.fromProto).toVector,
battalions = availableBattalions.map(BattalionConverter.fromProto).toVector,
battalionTypes = gameState.battalionTypes.map(BattalionTypeConverter.fromProto).toVector
heroes = availableHeroes.toVector,
battalions = availableBattalions.toVector,
battalionTypes = gameState.battalionTypes.toVector
)
RecommendedBattleConfig(
units = units,
food = MarchSuppliesHelpers.foodToTakeOnAttack(
units.flatMap(_.battalionId).map(gameState.battalions).map(BattalionConverter.fromProto),
units.flatMap(_.battalionId).map(gameState.battalions),
originProvince.food
),
gold = MarchSuppliesHelpers.goldToTakeOnAttack(
@@ -102,13 +97,12 @@ object AvailableMarchCommandFactory extends AvailableCommandsFactoryForType {
)
}.toVector,
availableDestinationProvinces = availableDestinations,
suitableBattalionsForHeroes = SuitableBattalionsConverter.toProtoMap(
BattalionSuitability.suitableBattalionsForHeroes(
hs = availableHeroes.map(HeroConverter.fromProto).toVector,
bs = availableBattalions.map(BattalionConverter.fromProto).toVector,
bts = gameState.battalionTypes.map(BattalionTypeConverter.fromProto).toVector
)
),
suitableBattalionsForHeroes = BattalionSuitability
.suitableBattalionsForHeroes(
hs = availableHeroes.toVector,
bs = availableBattalions.toVector,
bts = gameState.battalionTypes.toVector
),
originProvinceId = originProvince.id,
goldAvailable = originProvince.gold,
foodAvailable = originProvince.food,

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